Compare commits
61 Commits
Author | SHA1 | Date | |
---|---|---|---|
43f5bb7773 | |||
e85cc0fcfc | |||
dc1948016f | |||
4410040a14 | |||
b2ed0b2c80 | |||
42fe653e1e | |||
8a81fa814f | |||
98f3fa9d7c | |||
202e27fe25 | |||
757ff527d0 | |||
41f5801b5e | |||
4c7657a90e | |||
aef650753e | |||
420f331be9 | |||
e0068c4d5d | |||
85661884a6 | |||
8f6203d296 | |||
2fa723dc3c | |||
a500fb72c5 | |||
02db0db733 | |||
c87b08ca8b | |||
fcc2ab1a48 | |||
7efda2f890 | |||
3794a61d2d | |||
c1d1ea9dde | |||
0d676a46c8 | |||
97db144e01 | |||
cec55127c8 | |||
f3f359bcfb | |||
601e6f4147 | |||
e228b4925c | |||
62e3ffe413 | |||
6af885fde0 | |||
dd15bba359 | |||
43fca7ff43 | |||
faa6af5694 | |||
d2ea7a0bfb | |||
3f6319e00b | |||
5601299648 | |||
6060c7cfe0 | |||
ba78c2783d | |||
48eee5f865 | |||
f4a8acdb46 | |||
1d6ba22598 | |||
e38be8d710 | |||
da5be3fb57 | |||
b5317a7f95 | |||
43afb16808 | |||
d5c56fb16c | |||
b94c1f280b | |||
acc59866a3 | |||
c9fc3e402d | |||
6c1317f978 | |||
89be438e66 | |||
9d6214e93a | |||
0640b24290 | |||
6eb9d9d973 | |||
9ecc3176a5 | |||
96434c5a54 | |||
4063c62a17 | |||
890c5b986c |
12
.github/workflows/build-code.yml
vendored
12
.github/workflows/build-code.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
node_version:
|
||||
- 18
|
||||
- 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
@ -24,16 +24,16 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
cache: 'yarn'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
run: npm ci
|
||||
|
||||
- name: Check formatting
|
||||
run: yarn format:check
|
||||
run: npm run format:check
|
||||
|
||||
- name: Execute tests
|
||||
run: yarn test
|
||||
run: npm test
|
||||
|
||||
- name: Build application
|
||||
run: yarn build:production
|
||||
run: npm run build:production
|
||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -5,8 +5,8 @@
|
||||
/tmp
|
||||
|
||||
# dependencies
|
||||
/.yarn
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
@ -34,10 +34,8 @@
|
||||
/coverage
|
||||
/dist
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
testem.log
|
||||
/typings
|
||||
yarn-error.log
|
||||
|
||||
# System Files
|
||||
.DS_Store
|
||||
|
109
CHANGELOG.md
109
CHANGELOG.md
@ -5,6 +5,113 @@ 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).
|
||||
|
||||
## 2.102.0 - 2024-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to clone an activity from the account detail dialog (experimental)
|
||||
- Added support to edit an activity from the account detail dialog (experimental)
|
||||
- Added support to clone an activity from the holding detail dialog (experimental)
|
||||
- Added support to edit an activity from the holding detail dialog (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the caching of the benchmarks in the markets overview by returning cached data and recalculating in the background when it expires
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Polish (`pl`)
|
||||
- Upgraded `Nx` from version `19.5.1` to `19.5.6`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the cache flush endpoint response
|
||||
|
||||
## 2.101.0 - 2024-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- Hardened container security by switching to a non-root user, setting the filesystem to read-only, and dropping unnecessary capabilities
|
||||
|
||||
## 2.100.0 - 2024-08-03
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to manage tags of holdings in the holding detail dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the color assignment in the chart of the holdings tab on the home page (experimental)
|
||||
- Persisted the view mode of the holdings tab on the home page (experimental)
|
||||
- Improved the language localization for Catalan (`ca`)
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
|
||||
## 2.99.0 - 2024-07-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the usage of `yarn` to `npm`
|
||||
- Upgraded `storybook` from version `7.0.9` to `8.2.5`
|
||||
- Downgraded `marked` from version `13.0.0` to `12.0.2`
|
||||
|
||||
## 2.98.0 - 2024-07-27
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for Catalan (`ca`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the account selector of the create or update activity dialog
|
||||
- Improved the handling of the numerical precision in the value component
|
||||
- Skipped derived currencies in the get quotes functionality of the data provider service
|
||||
- Improved the language localization for Spanish (`es`)
|
||||
- Upgraded `angular` from version `18.0.4` to `18.1.1`
|
||||
- Upgraded `Nx` from version `19.4.3` to `19.5.1`
|
||||
- Upgraded `prisma` from version `5.16.1` to `5.17.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the dividend import from a data provider for holdings without an account
|
||||
- Fixed an issue in the public page related to a non-existent access
|
||||
|
||||
## 2.97.0 - 2024-07-20
|
||||
|
||||
### Added
|
||||
|
||||
- Added _selfh.st_ to the _As seen in_ section on the landing page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the numerical precision in the holding detail dialog
|
||||
- Improved the handling of the numerical precision in the value component
|
||||
- Optimized the 7d data gathering by prioritizing the currencies
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `Node.js` from version `18` to `20` (`Dockerfile`)
|
||||
- Upgraded `Nx` from version `19.4.0` to `19.4.3`
|
||||
- Upgraded `prettier` from version `3.3.1` to `3.3.3`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the table sorting of the holdings tab on the home page
|
||||
|
||||
## 2.96.0 - 2024-07-13
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the chart of the holdings tab on the home page (experimental)
|
||||
- Separated the icon purposes in the `site.webmanifest`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the portfolio summary with the currency conversion of fees
|
||||
- Fixed an issue in the the search for a holding
|
||||
- Removed the show condition of the experimental features setting in the user settings
|
||||
|
||||
## 2.95.0 - 2024-07-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added a chart to the holdings tab of the home page (experimental)
|
||||
|
||||
## 2.94.0 - 2024-07-09
|
||||
|
||||
### Changed
|
||||
@ -4793,7 +4900,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Added the attribute `precision` in the value component
|
||||
- Added the attribute `precision` to the value component
|
||||
|
||||
### Fixed
|
||||
|
||||
|
@ -30,26 +30,26 @@ Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
||||
|
||||
#### Upgrade
|
||||
|
||||
1. Run `yarn nx migrate latest`
|
||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
|
||||
1. Run `npx nx migrate latest`
|
||||
1. Make sure `package.json` changes make sense and then run `npm install`
|
||||
1. Run `npx nx migrate --run-migrations`
|
||||
|
||||
### Prisma
|
||||
|
||||
#### Access database via GUI
|
||||
|
||||
Run `yarn database:gui`
|
||||
Run `npm run database:gui`
|
||||
|
||||
https://www.prisma.io/studio
|
||||
|
||||
#### Synchronize schema with database for prototyping
|
||||
|
||||
Run `yarn database:push`
|
||||
Run `npm run database:push`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
|
||||
|
||||
#### Create schema migration
|
||||
|
||||
Run `yarn prisma migrate dev --name added_job_title`
|
||||
Run `npm run prisma migrate dev --name added_job_title`
|
||||
|
||||
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
|
||||
|
45
Dockerfile
45
Dockerfile
@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM node:18-slim as builder
|
||||
FROM --platform=$BUILDPLATFORM node:20-slim AS builder
|
||||
|
||||
# Build application and add additional files
|
||||
WORKDIR /ghostfolio
|
||||
@ -8,18 +8,17 @@ WORKDIR /ghostfolio
|
||||
COPY ./CHANGELOG.md CHANGELOG.md
|
||||
COPY ./LICENSE LICENSE
|
||||
COPY ./package.json package.json
|
||||
COPY ./yarn.lock yarn.lock
|
||||
COPY ./.yarnrc .yarnrc
|
||||
COPY ./package-lock.json package-lock.json
|
||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||
|
||||
RUN apt update && apt install -y \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
openssl \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN yarn install
|
||||
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
openssl \
|
||||
python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN npm install
|
||||
|
||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||
@ -33,34 +32,36 @@ COPY ./tsconfig.base.json tsconfig.base.json
|
||||
COPY ./libs libs
|
||||
COPY ./apps apps
|
||||
|
||||
RUN yarn build:production
|
||||
RUN npm run build:production
|
||||
|
||||
# Prepare the dist image with additional node_modules
|
||||
WORKDIR /ghostfolio/dist/apps/api
|
||||
# package.json was generated by the build process, however the original
|
||||
# yarn.lock needs to be used to ensure the same versions
|
||||
COPY ./yarn.lock /ghostfolio/dist/apps/api/yarn.lock
|
||||
# package-lock.json needs to be used to ensure the same versions
|
||||
COPY ./package-lock.json /ghostfolio/dist/apps/api/package-lock.json
|
||||
|
||||
RUN yarn
|
||||
RUN npm install
|
||||
COPY prisma /ghostfolio/dist/apps/api/prisma
|
||||
|
||||
# Overwrite the generated package.json with the original one to ensure having
|
||||
# all the scripts
|
||||
COPY package.json /ghostfolio/dist/apps/api
|
||||
RUN yarn database:generate-typings
|
||||
RUN npm run database:generate-typings
|
||||
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:18-slim
|
||||
|
||||
FROM node:20-slim
|
||||
LABEL org.opencontainers.image.source="https://github.com/ghostfolio/ghostfolio"
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apt update && apt install -y \
|
||||
curl \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-suggests \
|
||||
curl \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
|
||||
COPY ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
|
||||
RUN chown -R node:node /ghostfolio
|
||||
WORKDIR /ghostfolio/apps/api
|
||||
EXPOSE ${PORT:-3333}
|
||||
USER node
|
||||
CMD [ "/ghostfolio/entrypoint.sh" ]
|
||||
|
75
README.md
75
README.md
@ -71,7 +71,7 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||
The frontend is built with [Angular](https://angular.dev) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||
|
||||
## Self-hosting
|
||||
|
||||
@ -87,21 +87,21 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
| Name | Type | Default Value | Description |
|
||||
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | string (`optional`) | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API key |
|
||||
| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
|
||||
| `REDIS_HOST` | string | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | string | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | number | | The port where _Redis_ is running |
|
||||
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | `string` (optional) | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key |
|
||||
| `DATABASE_URL` | `string` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ |
|
||||
| `REDIS_HOST` | `string` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | `string` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | `number` | | The port where _Redis_ is running |
|
||||
| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -149,16 +149,15 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- [Node.js](https://nodejs.org/en/download) (version 20+)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
||||
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `npm install`
|
||||
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
|
||||
1. Run `npm run database:setup` to initialize the database schema
|
||||
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Open https://localhost:4200/en in your browser
|
||||
@ -168,31 +167,31 @@ Ghostfolio is available for various home server systems, including [CasaOS](http
|
||||
|
||||
#### Debug
|
||||
|
||||
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
Run `npm run watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
|
||||
#### Serve
|
||||
|
||||
Run `yarn start:server`
|
||||
Run `npm run start:server`
|
||||
|
||||
### Start Client
|
||||
|
||||
Run `yarn start:client` and open https://localhost:4200/en in your browser
|
||||
Run `npm run start:client` and open https://localhost:4200/en in your browser
|
||||
|
||||
### Start _Storybook_
|
||||
|
||||
Run `yarn start:storybook`
|
||||
Run `npm run start:storybook`
|
||||
|
||||
### Migrate Database
|
||||
|
||||
With the following command you can keep your database schema in sync:
|
||||
|
||||
```bash
|
||||
yarn database:push
|
||||
npm run database:push
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run `yarn test`
|
||||
Run `npm test`
|
||||
|
||||
## Public API
|
||||
|
||||
@ -233,18 +232,18 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
| ---------- | ------------------- | ----------------------------------------------------------------------------- |
|
||||
| accountId | string (`optional`) | Id of the account |
|
||||
| comment | string (`optional`) | Comment of the activity |
|
||||
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
|
||||
| dataSource | string | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||
| date | string | Date in the format `ISO-8601` |
|
||||
| fee | number | Fee of the activity |
|
||||
| quantity | number | Quantity of the activity |
|
||||
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
|
||||
| type | string | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||
| unitPrice | number | Price per unit of the activity |
|
||||
| Field | Type | Description |
|
||||
| ------------ | ------------------- | ----------------------------------------------------------------------------- |
|
||||
| `accountId` | `string` (optional) | Id of the account |
|
||||
| `comment` | `string` (optional) | Comment of the activity |
|
||||
| `currency` | `string` | `CHF` \| `EUR` \| `USD` etc. |
|
||||
| `dataSource` | `string` | `COINGECKO` \| `MANUAL` (for type `ITEM`) \| `YAHOO` |
|
||||
| `date` | `string` | Date in the format `ISO-8601` |
|
||||
| `fee` | `number` | Fee of the activity |
|
||||
| `quantity` | `number` | Quantity of the activity |
|
||||
| `symbol` | `string` | Symbol of the activity (suitable for `dataSource`) |
|
||||
| `type` | `string` | `BUY` \| `DIVIDEND` \| `FEE` \| `INTEREST` \| `ITEM` \| `LIABILITY` \| `SELL` |
|
||||
| `unitPrice` | `number` | Price per unit of the activity |
|
||||
|
||||
#### Response
|
||||
|
||||
|
@ -174,8 +174,8 @@ export class AccountService {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
} = groupBy(filters, ({ type }) => {
|
||||
return type;
|
||||
});
|
||||
|
||||
if (filtersByAccount?.length > 0) {
|
||||
|
@ -81,10 +81,11 @@ export class AdminController {
|
||||
@Post('gather/max')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherMax(): Promise<void> {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
@ -107,10 +108,11 @@ export class AdminController {
|
||||
@Post('gather/profile-data')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
|
@ -21,18 +21,19 @@ import {
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem,
|
||||
AssetProfileIdentifier,
|
||||
EnhancedSymbolProfile,
|
||||
Filter,
|
||||
UniqueAsset
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Prisma,
|
||||
PrismaClient,
|
||||
Property,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
@ -58,7 +59,9 @@ export class AdminService {
|
||||
currency,
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset & { currency?: string }): Promise<SymbolProfile | never> {
|
||||
}: AssetProfileIdentifier & { currency?: string }): Promise<
|
||||
SymbolProfile | never
|
||||
> {
|
||||
try {
|
||||
if (dataSource === 'MANUAL') {
|
||||
return this.symbolProfileService.add({
|
||||
@ -95,7 +98,10 @@ export class AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
public async deleteProfileData({
|
||||
dataSource,
|
||||
symbol
|
||||
}: AssetProfileIdentifier) {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
await this.symbolProfileService.delete({ dataSource, symbol });
|
||||
}
|
||||
@ -212,104 +218,119 @@ export class AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
let [assetProfiles, count] = await Promise.all([
|
||||
this.prismaService.symbolProfile.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
comment: true,
|
||||
countries: true,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
id: true,
|
||||
name: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
sectors: true,
|
||||
symbol: true
|
||||
const extendedPrismaClient = this.getExtendedPrismaClient();
|
||||
|
||||
try {
|
||||
let [assetProfiles, count] = await Promise.all([
|
||||
extendedPrismaClient.symbolProfile.findMany({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
assetClass: true,
|
||||
assetSubClass: true,
|
||||
comment: true,
|
||||
countries: true,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
id: true,
|
||||
isUsedByUsersWithSubscription: true,
|
||||
name: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
sectors: true,
|
||||
symbol: true
|
||||
}
|
||||
}),
|
||||
this.prismaService.symbolProfile.count({ where })
|
||||
]);
|
||||
|
||||
let marketData: AdminMarketDataItem[] = await Promise.all(
|
||||
assetProfiles.map(
|
||||
async ({
|
||||
_count,
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
id,
|
||||
isUsedByUsersWithSubscription,
|
||||
name,
|
||||
Order,
|
||||
sectors,
|
||||
symbol
|
||||
}) => {
|
||||
const countriesCount = countries
|
||||
? Object.keys(countries).length
|
||||
: 0;
|
||||
const marketDataItemCount =
|
||||
marketDataItems.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
||||
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
currency,
|
||||
countriesCount,
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
symbol,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activitiesCount: _count.Order,
|
||||
date: Order?.[0]?.date,
|
||||
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (presetId) {
|
||||
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
|
||||
marketData = marketData.filter(({ countriesCount }) => {
|
||||
return countriesCount === 0;
|
||||
});
|
||||
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
|
||||
marketData = marketData.filter(({ sectorsCount }) => {
|
||||
return sectorsCount === 0;
|
||||
});
|
||||
}
|
||||
}),
|
||||
this.prismaService.symbolProfile.count({ where })
|
||||
]);
|
||||
|
||||
let marketData: AdminMarketDataItem[] = assetProfiles.map(
|
||||
({
|
||||
_count,
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
Order,
|
||||
sectors,
|
||||
symbol
|
||||
}) => {
|
||||
const countriesCount = countries ? Object.keys(countries).length : 0;
|
||||
const marketDataItemCount =
|
||||
marketDataItems.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
|
||||
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
currency,
|
||||
countriesCount,
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
symbol,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
activitiesCount: _count.Order,
|
||||
date: Order?.[0]?.date
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
if (presetId) {
|
||||
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
|
||||
marketData = marketData.filter(({ countriesCount }) => {
|
||||
return countriesCount === 0;
|
||||
});
|
||||
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
|
||||
marketData = marketData.filter(({ sectorsCount }) => {
|
||||
return sectorsCount === 0;
|
||||
});
|
||||
count = marketData.length;
|
||||
}
|
||||
|
||||
count = marketData.length;
|
||||
return {
|
||||
count,
|
||||
marketData
|
||||
};
|
||||
} finally {
|
||||
await extendedPrismaClient.$disconnect();
|
||||
|
||||
Logger.debug('Disconnect extended prisma client', 'AdminService');
|
||||
}
|
||||
|
||||
return {
|
||||
count,
|
||||
marketData
|
||||
};
|
||||
}
|
||||
|
||||
public async getMarketDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<AdminMarketDataDetails> {
|
||||
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
|
||||
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
|
||||
let currency: EnhancedSymbolProfile['currency'] = '-';
|
||||
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
|
||||
@ -370,7 +391,7 @@ export class AdminService {
|
||||
symbol,
|
||||
symbolMapping,
|
||||
url
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
|
||||
const symbolProfileOverrides = {
|
||||
assetClass: assetClass as AssetClass,
|
||||
assetSubClass: assetSubClass as AssetSubClass,
|
||||
@ -378,28 +399,28 @@ export class AdminService {
|
||||
url: url as string
|
||||
};
|
||||
|
||||
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset =
|
||||
{
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
holdings,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping,
|
||||
...(dataSource === 'MANUAL'
|
||||
? { assetClass, assetSubClass, name, url }
|
||||
: {
|
||||
SymbolProfileOverrides: {
|
||||
upsert: {
|
||||
create: symbolProfileOverrides,
|
||||
update: symbolProfileOverrides
|
||||
}
|
||||
const updatedSymbolProfile: AssetProfileIdentifier &
|
||||
Prisma.SymbolProfileUpdateInput = {
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
holdings,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping,
|
||||
...(dataSource === 'MANUAL'
|
||||
? { assetClass, assetSubClass, name, url }
|
||||
: {
|
||||
SymbolProfileOverrides: {
|
||||
upsert: {
|
||||
create: symbolProfileOverrides,
|
||||
update: symbolProfileOverrides
|
||||
}
|
||||
})
|
||||
};
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
|
||||
|
||||
@ -431,6 +452,52 @@ export class AdminService {
|
||||
return response;
|
||||
}
|
||||
|
||||
private getExtendedPrismaClient() {
|
||||
Logger.debug('Connect extended prisma client', 'AdminService');
|
||||
|
||||
const symbolProfileExtension = Prisma.defineExtension((client) => {
|
||||
return client.$extends({
|
||||
result: {
|
||||
symbolProfile: {
|
||||
isUsedByUsersWithSubscription: {
|
||||
compute: async ({ id }) => {
|
||||
const { _count } =
|
||||
await this.prismaService.symbolProfile.findUnique({
|
||||
select: {
|
||||
_count: {
|
||||
select: {
|
||||
Order: {
|
||||
where: {
|
||||
User: {
|
||||
Subscription: {
|
||||
some: {
|
||||
expiresAt: {
|
||||
gt: new Date()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
where: {
|
||||
id
|
||||
}
|
||||
});
|
||||
|
||||
return _count.Order > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return new PrismaClient().$extends(symbolProfileExtension);
|
||||
}
|
||||
|
||||
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
|
||||
const marketDataItems = await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
|
@ -4,9 +4,9 @@ import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||
import type {
|
||||
AssetProfileIdentifier,
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
BenchmarkResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||
@ -41,7 +41,9 @@ export class BenchmarkController {
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||
public async addBenchmark(
|
||||
@Body() { dataSource, symbol }: AssetProfileIdentifier
|
||||
) {
|
||||
try {
|
||||
const benchmark = await this.benchmarkService.addBenchmark({
|
||||
dataSource,
|
||||
|
@ -17,11 +17,11 @@ import {
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
Benchmark,
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkProperty,
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
BenchmarkResponse
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
||||
|
||||
@ -29,15 +29,19 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import {
|
||||
addHours,
|
||||
differenceInDays,
|
||||
eachDayOfInterval,
|
||||
format,
|
||||
isAfter,
|
||||
isSameDay,
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
import { isNumber, last, uniqBy } from 'lodash';
|
||||
import ms from 'ms';
|
||||
|
||||
import { BenchmarkValue } from './interfaces/benchmark-value.interface';
|
||||
|
||||
@Injectable()
|
||||
export class BenchmarkService {
|
||||
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
|
||||
@ -61,7 +65,10 @@ export class BenchmarkService {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) {
|
||||
public async getBenchmarkTrends({
|
||||
dataSource,
|
||||
symbol
|
||||
}: AssetProfileIdentifier) {
|
||||
const historicalData = await this.marketDataService.marketDataItems({
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
@ -89,99 +96,26 @@ export class BenchmarkService {
|
||||
enableSharing = false,
|
||||
useCache = true
|
||||
} = {}): Promise<BenchmarkResponse['benchmarks']> {
|
||||
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||
|
||||
if (useCache) {
|
||||
try {
|
||||
benchmarks = JSON.parse(
|
||||
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
||||
const cachedBenchmarkValue = await this.redisCacheService.get(
|
||||
this.CACHE_KEY_BENCHMARKS
|
||||
);
|
||||
|
||||
if (benchmarks) {
|
||||
return benchmarks;
|
||||
const { benchmarks, expiration }: BenchmarkValue =
|
||||
JSON.parse(cachedBenchmarkValue);
|
||||
|
||||
if (isAfter(new Date(), new Date(expiration))) {
|
||||
this.calculateAndCacheBenchmarks({
|
||||
enableSharing
|
||||
});
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
|
||||
enableSharing
|
||||
});
|
||||
|
||||
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
|
||||
[];
|
||||
const promisesBenchmarkTrends: Promise<{
|
||||
trend50d: BenchmarkTrend;
|
||||
trend200d: BenchmarkTrend;
|
||||
}>[] = [];
|
||||
|
||||
const quotes = await this.dataProviderService.getQuotes({
|
||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
}),
|
||||
requestTimeout: ms('30 seconds'),
|
||||
useCache: false
|
||||
});
|
||||
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
promisesAllTimeHighs.push(
|
||||
this.marketDataService.getMax({ dataSource, symbol })
|
||||
);
|
||||
promisesBenchmarkTrends.push(
|
||||
this.getBenchmarkTrends({ dataSource, symbol })
|
||||
);
|
||||
}
|
||||
|
||||
const [allTimeHighs, benchmarkTrends] = await Promise.all([
|
||||
Promise.all(promisesAllTimeHighs),
|
||||
Promise.all(promisesBenchmarkTrends)
|
||||
]);
|
||||
let storeInCache = useCache;
|
||||
|
||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||
const { marketPrice } =
|
||||
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
||||
|
||||
let performancePercentFromAllTimeHigh = 0;
|
||||
|
||||
if (allTimeHigh?.marketPrice && marketPrice) {
|
||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||
allTimeHigh.marketPrice,
|
||||
marketPrice
|
||||
);
|
||||
} else {
|
||||
storeInCache = false;
|
||||
}
|
||||
|
||||
return {
|
||||
dataSource: benchmarkAssetProfiles[index].dataSource,
|
||||
marketCondition: this.getMarketCondition(
|
||||
performancePercentFromAllTimeHigh
|
||||
),
|
||||
name: benchmarkAssetProfiles[index].name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
date: allTimeHigh?.date,
|
||||
performancePercent:
|
||||
performancePercentFromAllTimeHigh >= 0
|
||||
? 0
|
||||
: performancePercentFromAllTimeHigh
|
||||
}
|
||||
},
|
||||
symbol: benchmarkAssetProfiles[index].symbol,
|
||||
trend50d: benchmarkTrends[index].trend50d,
|
||||
trend200d: benchmarkTrends[index].trend200d
|
||||
};
|
||||
});
|
||||
|
||||
if (storeInCache) {
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks),
|
||||
ms('2 hours') / 1000
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
return this.calculateAndCacheBenchmarks({ enableSharing });
|
||||
}
|
||||
|
||||
public async getBenchmarkAssetProfiles({
|
||||
@ -228,7 +162,7 @@ export class BenchmarkService {
|
||||
endDate?: Date;
|
||||
startDate: Date;
|
||||
userCurrency: string;
|
||||
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
|
||||
const marketData: { date: string; value: number }[] = [];
|
||||
|
||||
const days = differenceInDays(endDate, startDate) + 1;
|
||||
@ -348,7 +282,7 @@ export class BenchmarkService {
|
||||
public async addBenchmark({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
|
||||
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||
where: {
|
||||
dataSource,
|
||||
@ -385,7 +319,7 @@ export class BenchmarkService {
|
||||
public async deleteBenchmark({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Promise<Partial<SymbolProfile>> {
|
||||
}: AssetProfileIdentifier): Promise<Partial<SymbolProfile>> {
|
||||
const assetProfile = await this.prismaService.symbolProfile.findFirst({
|
||||
where: {
|
||||
dataSource,
|
||||
@ -419,6 +353,95 @@ export class BenchmarkService {
|
||||
};
|
||||
}
|
||||
|
||||
private async calculateAndCacheBenchmarks({
|
||||
enableSharing = false
|
||||
}): Promise<BenchmarkResponse['benchmarks']> {
|
||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
|
||||
enableSharing
|
||||
});
|
||||
|
||||
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
|
||||
[];
|
||||
const promisesBenchmarkTrends: Promise<{
|
||||
trend50d: BenchmarkTrend;
|
||||
trend200d: BenchmarkTrend;
|
||||
}>[] = [];
|
||||
|
||||
const quotes = await this.dataProviderService.getQuotes({
|
||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
}),
|
||||
requestTimeout: ms('30 seconds'),
|
||||
useCache: false
|
||||
});
|
||||
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
promisesAllTimeHighs.push(
|
||||
this.marketDataService.getMax({ dataSource, symbol })
|
||||
);
|
||||
promisesBenchmarkTrends.push(
|
||||
this.getBenchmarkTrends({ dataSource, symbol })
|
||||
);
|
||||
}
|
||||
|
||||
const [allTimeHighs, benchmarkTrends] = await Promise.all([
|
||||
Promise.all(promisesAllTimeHighs),
|
||||
Promise.all(promisesBenchmarkTrends)
|
||||
]);
|
||||
let storeInCache = true;
|
||||
|
||||
const benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||
const { marketPrice } =
|
||||
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
||||
|
||||
let performancePercentFromAllTimeHigh = 0;
|
||||
|
||||
if (allTimeHigh?.marketPrice && marketPrice) {
|
||||
performancePercentFromAllTimeHigh = this.calculateChangeInPercentage(
|
||||
allTimeHigh.marketPrice,
|
||||
marketPrice
|
||||
);
|
||||
} else {
|
||||
storeInCache = false;
|
||||
}
|
||||
|
||||
return {
|
||||
dataSource: benchmarkAssetProfiles[index].dataSource,
|
||||
marketCondition: this.getMarketCondition(
|
||||
performancePercentFromAllTimeHigh
|
||||
),
|
||||
name: benchmarkAssetProfiles[index].name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
date: allTimeHigh?.date,
|
||||
performancePercent:
|
||||
performancePercentFromAllTimeHigh >= 0
|
||||
? 0
|
||||
: performancePercentFromAllTimeHigh
|
||||
}
|
||||
},
|
||||
symbol: benchmarkAssetProfiles[index].symbol,
|
||||
trend50d: benchmarkTrends[index].trend50d,
|
||||
trend200d: benchmarkTrends[index].trend200d
|
||||
};
|
||||
});
|
||||
|
||||
if (storeInCache) {
|
||||
const expiration = addHours(new Date(), 2);
|
||||
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(<BenchmarkValue>{
|
||||
benchmarks,
|
||||
expiration: expiration.getTime()
|
||||
}),
|
||||
ms('12 hours') / 1000
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
private getMarketCondition(
|
||||
aPerformanceInPercent: number
|
||||
): Benchmark['marketCondition'] {
|
||||
|
@ -0,0 +1,6 @@
|
||||
import { BenchmarkResponse } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface BenchmarkValue {
|
||||
benchmarks: BenchmarkResponse['benchmarks'];
|
||||
expiration: number;
|
||||
}
|
2
apps/api/src/app/cache/cache.controller.ts
vendored
2
apps/api/src/app/cache/cache.controller.ts
vendored
@ -14,6 +14,6 @@ export class CacheController {
|
||||
@Post('flush')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async flushCache(): Promise<void> {
|
||||
return this.redisCacheService.reset();
|
||||
await this.redisCacheService.reset();
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ import {
|
||||
getAssetProfileIdentifier,
|
||||
parseDate
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AccountWithPlatform,
|
||||
OrderWithAccount,
|
||||
@ -51,7 +51,7 @@ export class ImportService {
|
||||
dataSource,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: UniqueAsset & { userCurrency: string }): Promise<Activity[]> {
|
||||
}: AssetProfileIdentifier & { userCurrency: string }): Promise<Activity[]> {
|
||||
try {
|
||||
const { firstBuyDate, historicalData, orders } =
|
||||
await this.portfolioService.getPosition(dataSource, undefined, symbol);
|
||||
@ -72,9 +72,13 @@ export class ImportService {
|
||||
})
|
||||
]);
|
||||
|
||||
const accounts = orders.map((order) => {
|
||||
return order.Account;
|
||||
});
|
||||
const accounts = orders
|
||||
.filter(({ Account }) => {
|
||||
return !!Account;
|
||||
})
|
||||
.map(({ Account }) => {
|
||||
return Account;
|
||||
});
|
||||
|
||||
const Account = this.isUniqueAccount(accounts) ? accounts[0] : undefined;
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -17,7 +17,7 @@ export class LogoService {
|
||||
public async getLogoByDataSourceAndSymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset) {
|
||||
}: AssetProfileIdentifier) {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
|
@ -36,7 +36,7 @@ import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { Activities } from './interfaces/activities.interface';
|
||||
import { Activities, Activity } from './interfaces/activities.interface';
|
||||
import { OrderService } from './order.service';
|
||||
import { UpdateOrderDto } from './update-order.dto';
|
||||
|
||||
@ -140,6 +140,38 @@ export class OrderController {
|
||||
return { activities, count };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getOrderById(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Param('id') id: string
|
||||
): Promise<Activity> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const { activities } = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
const activity = activities.find((activity) => {
|
||||
return activity.id === id;
|
||||
});
|
||||
|
||||
if (!activity) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return activity;
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createOrder)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
|
@ -11,9 +11,9 @@ import {
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
EnhancedSymbolProfile,
|
||||
Filter,
|
||||
UniqueAsset
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
@ -46,6 +46,39 @@ export class OrderService {
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async assignTags({
|
||||
dataSource,
|
||||
symbol,
|
||||
tags,
|
||||
userId
|
||||
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
|
||||
const orders = await this.prismaService.order.findMany({
|
||||
where: {
|
||||
userId,
|
||||
SymbolProfile: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
orders.map(({ id }) =>
|
||||
this.prismaService.order.update({
|
||||
data: {
|
||||
tags: {
|
||||
// The set operation replaces all existing connections with the provided ones
|
||||
set: tags.map(({ id }) => {
|
||||
return { id };
|
||||
})
|
||||
}
|
||||
},
|
||||
where: { id }
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async createOrder(
|
||||
data: Prisma.OrderCreateInput & {
|
||||
accountId?: string;
|
||||
@ -252,7 +285,7 @@ export class OrderService {
|
||||
return count;
|
||||
}
|
||||
|
||||
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
|
||||
public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
return this.prismaService.order.findFirst({
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
@ -312,10 +345,14 @@ export class OrderService {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
} = groupBy(filters, ({ type }) => {
|
||||
return type;
|
||||
});
|
||||
|
||||
const searchQuery = filters?.find(({ type }) => {
|
||||
return type === 'SEARCH_QUERY';
|
||||
})?.id;
|
||||
|
||||
if (filtersByAccount?.length > 0) {
|
||||
where.accountId = {
|
||||
in: filtersByAccount.map(({ id }) => {
|
||||
@ -357,6 +394,30 @@ export class OrderService {
|
||||
};
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [
|
||||
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||
];
|
||||
|
||||
if (where.SymbolProfile) {
|
||||
where.SymbolProfile = {
|
||||
AND: [
|
||||
where.SymbolProfile,
|
||||
{
|
||||
OR: searchQueryWhereInput
|
||||
}
|
||||
]
|
||||
};
|
||||
} else {
|
||||
where.SymbolProfile = {
|
||||
OR: searchQueryWhereInput
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (filtersByTag?.length > 0) {
|
||||
where.tags = {
|
||||
some: {
|
||||
@ -403,7 +464,7 @@ export class OrderService {
|
||||
this.prismaService.order.count({ where })
|
||||
]);
|
||||
|
||||
const uniqueAssets = uniqBy(
|
||||
const assetProfileIdentifiers = uniqBy(
|
||||
orders.map(({ SymbolProfile }) => {
|
||||
return {
|
||||
dataSource: SymbolProfile.dataSource,
|
||||
@ -418,8 +479,9 @@ export class OrderService {
|
||||
}
|
||||
);
|
||||
|
||||
const assetProfiles =
|
||||
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
|
||||
const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
assetProfileIdentifiers
|
||||
);
|
||||
|
||||
const activities = orders.map((order) => {
|
||||
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
SymbolMetrics
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||
|
||||
export class MWRPortfolioCalculator extends PortfolioCalculator {
|
||||
@ -27,7 +30,7 @@ export class MWRPortfolioCalculator extends PortfolioCalculator {
|
||||
};
|
||||
start: Date;
|
||||
step?: number;
|
||||
} & UniqueAsset): SymbolMetrics {
|
||||
} & AssetProfileIdentifier): SymbolMetrics {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
}
|
||||
|
@ -19,12 +19,12 @@ import {
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
DataProviderInfo,
|
||||
HistoricalDataItem,
|
||||
InvestmentItem,
|
||||
ResponseError,
|
||||
SymbolMetrics,
|
||||
UniqueAsset
|
||||
SymbolMetrics
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||
import { DateRange, GroupBy } from '@ghostfolio/common/types';
|
||||
@ -300,6 +300,12 @@ export abstract class PortfolioCalculator {
|
||||
const errors: ResponseError['errors'] = [];
|
||||
|
||||
for (const item of lastTransactionPoint.items) {
|
||||
const feeInBaseCurrency = item.fee.mul(
|
||||
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
|
||||
lastTransactionPoint.date
|
||||
]
|
||||
);
|
||||
|
||||
const marketPriceInBaseCurrency = (
|
||||
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
|
||||
).mul(
|
||||
@ -340,24 +346,25 @@ export abstract class PortfolioCalculator {
|
||||
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
||||
|
||||
positions.push({
|
||||
dividend: totalDividend,
|
||||
dividendInBaseCurrency: totalDividendInBaseCurrency,
|
||||
feeInBaseCurrency,
|
||||
timeWeightedInvestment,
|
||||
timeWeightedInvestmentWithCurrencyEffect,
|
||||
dividend: totalDividend,
|
||||
dividendInBaseCurrency: totalDividendInBaseCurrency,
|
||||
averagePrice: item.averagePrice,
|
||||
currency: item.currency,
|
||||
dataSource: item.dataSource,
|
||||
fee: item.fee,
|
||||
firstBuyDate: item.firstBuyDate,
|
||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||
grossPerformance: !hasErrors ? (grossPerformance ?? null) : null,
|
||||
grossPerformancePercentage: !hasErrors
|
||||
? grossPerformancePercentage ?? null
|
||||
? (grossPerformancePercentage ?? null)
|
||||
: null,
|
||||
grossPerformancePercentageWithCurrencyEffect: !hasErrors
|
||||
? grossPerformancePercentageWithCurrencyEffect ?? null
|
||||
? (grossPerformancePercentageWithCurrencyEffect ?? null)
|
||||
: null,
|
||||
grossPerformanceWithCurrencyEffect: !hasErrors
|
||||
? grossPerformanceWithCurrencyEffect ?? null
|
||||
? (grossPerformanceWithCurrencyEffect ?? null)
|
||||
: null,
|
||||
investment: totalInvestment,
|
||||
investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect,
|
||||
@ -365,15 +372,15 @@ export abstract class PortfolioCalculator {
|
||||
marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? null,
|
||||
marketPriceInBaseCurrency:
|
||||
marketPriceInBaseCurrency?.toNumber() ?? null,
|
||||
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
||||
netPerformance: !hasErrors ? (netPerformance ?? null) : null,
|
||||
netPerformancePercentage: !hasErrors
|
||||
? netPerformancePercentage ?? null
|
||||
? (netPerformancePercentage ?? null)
|
||||
: null,
|
||||
netPerformancePercentageWithCurrencyEffect: !hasErrors
|
||||
? netPerformancePercentageWithCurrencyEffect ?? null
|
||||
? (netPerformancePercentageWithCurrencyEffect ?? null)
|
||||
: null,
|
||||
netPerformanceWithCurrencyEffect: !hasErrors
|
||||
? netPerformanceWithCurrencyEffect ?? null
|
||||
? (netPerformanceWithCurrencyEffect ?? null)
|
||||
: null,
|
||||
quantity: item.quantity,
|
||||
symbol: item.symbol,
|
||||
@ -898,7 +905,7 @@ export abstract class PortfolioCalculator {
|
||||
};
|
||||
start: Date;
|
||||
step?: number;
|
||||
} & UniqueAsset): SymbolMetrics;
|
||||
} & AssetProfileIdentifier): SymbolMetrics;
|
||||
|
||||
public getTransactionPoints() {
|
||||
return this.transactionPoints;
|
||||
|
@ -168,6 +168,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('3.2'),
|
||||
feeInBaseCurrency: new Big('3.2'),
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||
|
@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('3.2'),
|
||||
feeInBaseCurrency: new Big('3.2'),
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||
|
@ -138,6 +138,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('1.55'),
|
||||
feeInBaseCurrency: new Big('1.55'),
|
||||
firstBuyDate: '2021-11-30',
|
||||
grossPerformance: new Big('24.6'),
|
||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||
|
@ -166,6 +166,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
feeInBaseCurrency: new Big('0'),
|
||||
firstBuyDate: '2015-01-01',
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
|
@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('49'),
|
||||
feeInBaseCurrency: new Big('49'),
|
||||
firstBuyDate: '2021-09-01',
|
||||
grossPerformance: null,
|
||||
grossPerformancePercentage: null,
|
||||
|
@ -151,6 +151,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('1'),
|
||||
feeInBaseCurrency: new Big('0.9238'),
|
||||
firstBuyDate: '2023-01-03',
|
||||
grossPerformance: new Big('27.33'),
|
||||
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||
@ -177,7 +178,7 @@ describe('PortfolioCalculator', () => {
|
||||
valueInBaseCurrency: new Big('103.10483')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('1'),
|
||||
totalFeesWithCurrencyEffect: new Big('0.9238'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('89.12'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||
|
@ -123,6 +123,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
feeInBaseCurrency: new Big('0'),
|
||||
firstBuyDate: '2022-01-01',
|
||||
grossPerformance: null,
|
||||
grossPerformancePercentage: null,
|
||||
|
@ -153,6 +153,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('4.25'),
|
||||
feeInBaseCurrency: new Big('4.25'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||
|
@ -183,6 +183,7 @@ describe('PortfolioCalculator', () => {
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
feeInBaseCurrency: new Big('0'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
|
@ -2,7 +2,10 @@ import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/po
|
||||
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
|
||||
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
SymbolMetrics
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
@ -34,9 +37,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.fee) {
|
||||
if (currentPosition.feeInBaseCurrency) {
|
||||
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
|
||||
currentPosition.fee
|
||||
currentPosition.feeInBaseCurrency
|
||||
);
|
||||
}
|
||||
|
||||
@ -151,7 +154,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
};
|
||||
start: Date;
|
||||
step?: number;
|
||||
} & UniqueAsset): SymbolMetrics {
|
||||
} & AssetProfileIdentifier): SymbolMetrics {
|
||||
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
|
||||
const currentValues: { [date: string]: Big } = {};
|
||||
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
@ -24,32 +24,32 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
|
||||
});
|
||||
},
|
||||
getRange: ({
|
||||
assetProfileIdentifiers,
|
||||
dateRangeEnd,
|
||||
dateRangeStart,
|
||||
uniqueAssets
|
||||
dateRangeStart
|
||||
}: {
|
||||
assetProfileIdentifiers: AssetProfileIdentifier[];
|
||||
dateRangeEnd: Date;
|
||||
dateRangeStart: Date;
|
||||
uniqueAssets: UniqueAsset[];
|
||||
}) => {
|
||||
return Promise.resolve<MarketData[]>([
|
||||
{
|
||||
createdAt: dateRangeStart,
|
||||
dataSource: uniqueAssets[0].dataSource,
|
||||
dataSource: assetProfileIdentifiers[0].dataSource,
|
||||
date: dateRangeStart,
|
||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||
marketPrice: 1841.823902,
|
||||
state: 'CLOSE',
|
||||
symbol: uniqueAssets[0].symbol
|
||||
symbol: assetProfileIdentifiers[0].symbol
|
||||
},
|
||||
{
|
||||
createdAt: dateRangeEnd,
|
||||
dataSource: uniqueAssets[0].dataSource,
|
||||
dataSource: assetProfileIdentifiers[0].dataSource,
|
||||
date: dateRangeEnd,
|
||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||
marketPrice: 1847.839966,
|
||||
state: 'CLOSE',
|
||||
symbol: uniqueAssets[0].symbol
|
||||
symbol: assetProfileIdentifiers[0].symbol
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
@ -3,9 +3,9 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
DataProviderInfo,
|
||||
ResponseError,
|
||||
UniqueAsset
|
||||
ResponseError
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
@ -80,17 +80,16 @@ export class CurrentRateService {
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueAssets: UniqueAsset[] = dataGatheringItems.map(
|
||||
({ dataSource, symbol }) => {
|
||||
const assetProfileIdentifiers: AssetProfileIdentifier[] =
|
||||
dataGatheringItems.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
promises.push(
|
||||
this.marketDataService
|
||||
.getRange({
|
||||
dateQuery,
|
||||
uniqueAssets
|
||||
assetProfileIdentifiers,
|
||||
dateQuery
|
||||
})
|
||||
.then((data) => {
|
||||
return data.map(({ dataSource, date, marketPrice, symbol }) => {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface GetValueObject extends UniqueAsset {
|
||||
export interface GetValueObject extends AssetProfileIdentifier {
|
||||
date: Date;
|
||||
marketPrice: number;
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import {
|
||||
hasNotDefinedValuesInObject,
|
||||
@ -29,7 +30,8 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
hasReadRestrictedAccessPermission,
|
||||
isRestrictedView
|
||||
isRestrictedView,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
DateRange,
|
||||
@ -38,12 +40,14 @@ import type {
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
@ -51,12 +55,13 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { UpdateHoldingTagsDto } from './update-holding-tags.dto';
|
||||
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
@ -496,9 +501,6 @@ export class PortfolioController {
|
||||
@Param('accessId') accessId
|
||||
): Promise<PortfolioPublicDetails> {
|
||||
const access = await this.accessService.access({ id: accessId });
|
||||
const user = await this.userService.user({
|
||||
id: access.userId
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
throw new HttpException(
|
||||
@ -508,6 +510,11 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
let hasDetails = true;
|
||||
|
||||
const user = await this.userService.user({
|
||||
id: access.userId
|
||||
});
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
@ -564,23 +571,23 @@ export class PortfolioController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getPosition(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<PortfolioHoldingDetail> {
|
||||
const position = await this.portfolioService.getPosition(
|
||||
const holding = await this.portfolioService.getPosition(
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
|
||||
if (position) {
|
||||
return position;
|
||||
if (!holding) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
return holding;
|
||||
}
|
||||
|
||||
@Get('report')
|
||||
@ -603,4 +610,36 @@ export class PortfolioController {
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateOrder)
|
||||
@Put('position/:dataSource/:symbol/tags')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateHoldingTags(
|
||||
@Body() data: UpdateHoldingTagsDto,
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
const holding = await this.portfolioService.getPosition(
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
|
||||
if (!holding) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
await this.portfolioService.updateTags({
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol,
|
||||
tags: data.tags,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,78 +0,0 @@
|
||||
import { Big } from 'big.js';
|
||||
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
describe('PortfolioService', () => {
|
||||
let portfolioService: PortfolioService;
|
||||
|
||||
beforeAll(async () => {
|
||||
portfolioService = new PortfolioService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('annualized performance percentage', () => {
|
||||
it('Get annualized performance', async () => {
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
||||
netPerformancePercentage: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 0,
|
||||
netPerformancePercentage: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
/**
|
||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
||||
*/
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 65, // < 1 year
|
||||
netPerformancePercentage: new Big(0.1025)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.729705);
|
||||
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 365, // 1 year
|
||||
netPerformancePercentage: new Big(0.05)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.05);
|
||||
|
||||
/**
|
||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
||||
*/
|
||||
expect(
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 575, // > 1 year
|
||||
netPerformancePercentage: new Big(0.2374)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.145);
|
||||
});
|
||||
});
|
||||
});
|
@ -18,6 +18,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { getAnnualizedPerformancePercent } from '@ghostfolio/common/calculation-helper';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
EMERGENCY_FUND_TAG_ID,
|
||||
@ -58,7 +59,8 @@ import {
|
||||
DataSource,
|
||||
Order,
|
||||
Platform,
|
||||
Prisma
|
||||
Prisma,
|
||||
Tag
|
||||
} from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import {
|
||||
@ -70,7 +72,7 @@ import {
|
||||
parseISO,
|
||||
set
|
||||
} from 'date-fns';
|
||||
import { isEmpty, isNumber, last, uniq, uniqBy } from 'lodash';
|
||||
import { isEmpty, uniq, uniqBy } from 'lodash';
|
||||
|
||||
import { PortfolioCalculator } from './calculator/portfolio-calculator';
|
||||
import {
|
||||
@ -206,24 +208,6 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercentage
|
||||
}: {
|
||||
daysInMarket: number;
|
||||
netPerformancePercentage: Big;
|
||||
}): Big {
|
||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
||||
|
||||
return new Big(
|
||||
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
|
||||
).minus(1);
|
||||
}
|
||||
|
||||
return new Big(0);
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
activities,
|
||||
groupBy
|
||||
@ -713,7 +697,7 @@ export class PortfolioService {
|
||||
return Account;
|
||||
});
|
||||
|
||||
const dividendYieldPercent = this.getAnnualizedPerformancePercent({
|
||||
const dividendYieldPercent = getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||
netPerformancePercentage: timeWeightedInvestment.eq(0)
|
||||
? new Big(0)
|
||||
@ -721,7 +705,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const dividendYieldPercentWithCurrencyEffect =
|
||||
this.getAnnualizedPerformancePercent({
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
|
||||
0
|
||||
@ -1321,6 +1305,24 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public async updateTags({
|
||||
dataSource,
|
||||
impersonationId,
|
||||
symbol,
|
||||
tags,
|
||||
userId
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
impersonationId: string;
|
||||
symbol: string;
|
||||
tags: Tag[];
|
||||
userId: string;
|
||||
}) {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
|
||||
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
|
||||
}
|
||||
|
||||
private async getCashPositions({
|
||||
cashDetails,
|
||||
userCurrency,
|
||||
@ -1724,13 +1726,13 @@ export class PortfolioService {
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
|
||||
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
|
||||
const annualizedPerformancePercent = getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercentage: new Big(netPerformancePercentage)
|
||||
})?.toNumber();
|
||||
|
||||
const annualizedPerformancePercentWithCurrencyEffect =
|
||||
this.getAnnualizedPerformancePercent({
|
||||
getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercentage: new Big(
|
||||
netPerformancePercentageWithCurrencyEffect
|
||||
|
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
7
apps/api/src/app/portfolio/update-holding-tags.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { Tag } from '@prisma/client';
|
||||
import { IsArray } from 'class-validator';
|
||||
|
||||
export class UpdateHoldingTagsDto {
|
||||
@IsArray()
|
||||
tags: Tag[];
|
||||
}
|
@ -1,6 +1,6 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
@ -28,7 +28,7 @@ export class RedisCacheService {
|
||||
return `portfolio-snapshot-${userId}`;
|
||||
}
|
||||
|
||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||
public getQuoteKey({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
HistoricalDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface SymbolItem extends UniqueAsset {
|
||||
export interface SymbolItem extends AssetProfileIdentifier {
|
||||
currency: string;
|
||||
historicalData: HistoricalDataItem[];
|
||||
marketPrice: number;
|
||||
|
@ -40,13 +40,13 @@ export class SymbolService {
|
||||
const days = includeHistoricalData;
|
||||
|
||||
const marketData = await this.marketDataService.getRange({
|
||||
dateQuery: { gte: subDays(new Date(), days) },
|
||||
uniqueAssets: [
|
||||
assetProfileIdentifiers: [
|
||||
{
|
||||
dataSource: dataGatheringItem.dataSource,
|
||||
symbol: dataGatheringItem.symbol
|
||||
}
|
||||
]
|
||||
],
|
||||
dateQuery: { gte: subDays(new Date(), days) }
|
||||
});
|
||||
|
||||
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
||||
|
@ -2,6 +2,7 @@ import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
import type {
|
||||
ColorScheme,
|
||||
DateRange,
|
||||
HoldingsViewMode,
|
||||
ViewMode
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
@ -66,6 +67,10 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
'filters.tags'?: string[];
|
||||
|
||||
@IsIn(<HoldingsViewMode[]>['CHART', 'TABLE'])
|
||||
@IsOptional()
|
||||
holdingsViewMode?: HoldingsViewMode;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExperimentalFeatures?: boolean;
|
||||
|
@ -190,7 +190,7 @@ export class UserService {
|
||||
(user.Settings.settings as UserSettings).dateRange =
|
||||
(user.Settings.settings as UserSettings).viewMode === 'ZEN'
|
||||
? 'max'
|
||||
: (user.Settings.settings as UserSettings)?.dateRange ?? 'max';
|
||||
: ((user.Settings.settings as UserSettings)?.dateRange ?? 'max');
|
||||
|
||||
// Set default value for view mode
|
||||
if (!(user.Settings.settings as UserSettings).viewMode) {
|
||||
@ -237,11 +237,15 @@ export class UserService {
|
||||
|
||||
currentPermissions = without(
|
||||
currentPermissions,
|
||||
permissions.accessHoldingsChart,
|
||||
permissions.createAccess
|
||||
);
|
||||
|
||||
// Reset benchmark
|
||||
user.Settings.settings.benchmark = undefined;
|
||||
|
||||
// Reset holdings view mode
|
||||
user.Settings.settings.holdingsViewMode = undefined;
|
||||
} else if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
|
||||
|
@ -4,6 +4,12 @@
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/ca</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -441,10 +447,10 @@
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<!--
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pl</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
-->
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pt</loc>
|
||||
|
@ -45,10 +45,11 @@ export class CronService {
|
||||
@Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME)
|
||||
public async runEverySundayAtTwelvePm() {
|
||||
if (await this.isDataGatheringEnabled()) {
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
const assetProfileIdentifiers =
|
||||
await this.dataGatheringService.getAllAssetProfileIdentifiers();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
assetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
data: {
|
||||
dataSource,
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Process, Processor } from '@nestjs/bull';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@ -35,7 +35,7 @@ export class DataGatheringProcessor {
|
||||
) {}
|
||||
|
||||
@Process({ concurrency: 1, name: GATHER_ASSET_PROFILE_PROCESS })
|
||||
public async gatherAssetProfile(job: Job<UniqueAsset>) {
|
||||
public async gatherAssetProfile(job: Job<AssetProfileIdentifier>) {
|
||||
try {
|
||||
Logger.log(
|
||||
`Asset profile data gathering has been started for ${job.data.symbol} (${job.data.dataSource})`,
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
DATA_GATHERING_QUEUE,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_LOW,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
|
||||
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
|
||||
PROPERTY_BENCHMARKS
|
||||
@ -19,7 +20,10 @@ import {
|
||||
getAssetProfileIdentifier,
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
BenchmarkProperty
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
@ -62,9 +66,22 @@ export class DataGatheringService {
|
||||
}
|
||||
|
||||
public async gather7Days() {
|
||||
const dataGatheringItems = await this.getSymbols7D();
|
||||
await this.gatherSymbols({
|
||||
dataGatheringItems,
|
||||
dataGatheringItems: await this.getCurrencies7D(),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
|
||||
});
|
||||
|
||||
await this.gatherSymbols({
|
||||
dataGatheringItems: await this.getSymbols7D({
|
||||
withUserSubscription: true
|
||||
}),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
});
|
||||
|
||||
await this.gatherSymbols({
|
||||
dataGatheringItems: await this.getSymbols7D({
|
||||
withUserSubscription: false
|
||||
}),
|
||||
priority: DATA_GATHERING_QUEUE_PRIORITY_LOW
|
||||
});
|
||||
}
|
||||
@ -77,7 +94,7 @@ export class DataGatheringService {
|
||||
});
|
||||
}
|
||||
|
||||
public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
public async gatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
await this.marketDataService.deleteMany({ dataSource, symbol });
|
||||
|
||||
const dataGatheringItems = (await this.getSymbolsMax()).filter(
|
||||
@ -132,23 +149,29 @@ export class DataGatheringService {
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) {
|
||||
let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => {
|
||||
return dataGatheringItem.dataSource !== 'MANUAL';
|
||||
});
|
||||
public async gatherAssetProfiles(
|
||||
aAssetProfileIdentifiers?: AssetProfileIdentifier[]
|
||||
) {
|
||||
let assetProfileIdentifiers = aAssetProfileIdentifiers?.filter(
|
||||
(dataGatheringItem) => {
|
||||
return dataGatheringItem.dataSource !== 'MANUAL';
|
||||
}
|
||||
);
|
||||
|
||||
if (!uniqueAssets) {
|
||||
uniqueAssets = await this.getUniqueAssets();
|
||||
if (!assetProfileIdentifiers) {
|
||||
assetProfileIdentifiers = await this.getAllAssetProfileIdentifiers();
|
||||
}
|
||||
|
||||
if (uniqueAssets.length <= 0) {
|
||||
if (assetProfileIdentifiers.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assetProfiles =
|
||||
await this.dataProviderService.getAssetProfiles(uniqueAssets);
|
||||
const symbolProfiles =
|
||||
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
|
||||
const assetProfiles = await this.dataProviderService.getAssetProfiles(
|
||||
assetProfileIdentifiers
|
||||
);
|
||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
assetProfileIdentifiers
|
||||
);
|
||||
|
||||
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
|
||||
const symbolMapping = symbolProfiles.find((symbolProfile) => {
|
||||
@ -234,7 +257,7 @@ export class DataGatheringService {
|
||||
'DataGatheringService'
|
||||
);
|
||||
|
||||
if (uniqueAssets.length === 1) {
|
||||
if (assetProfileIdentifiers.length === 1) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -270,7 +293,9 @@ export class DataGatheringService {
|
||||
);
|
||||
}
|
||||
|
||||
public async getUniqueAssets(): Promise<UniqueAsset[]> {
|
||||
public async getAllAssetProfileIdentifiers(): Promise<
|
||||
AssetProfileIdentifier[]
|
||||
> {
|
||||
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }]
|
||||
});
|
||||
@ -290,73 +315,83 @@ export class DataGatheringService {
|
||||
});
|
||||
}
|
||||
|
||||
private getEarliestDate(aStartDate: Date) {
|
||||
return min([aStartDate, subYears(new Date(), 10)]);
|
||||
}
|
||||
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
dataSource: true,
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
}
|
||||
});
|
||||
|
||||
// Only consider symbols with incomplete market data for the last
|
||||
// 7 days
|
||||
const symbolsWithCompleteMarketData = (
|
||||
private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise<
|
||||
AssetProfileIdentifier[]
|
||||
> {
|
||||
return (
|
||||
await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['symbol'],
|
||||
by: ['dataSource', 'symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
where: {
|
||||
date: { gt: startDate },
|
||||
date: { gt: subDays(resetHours(new Date()), 7) },
|
||||
state: 'CLOSE'
|
||||
}
|
||||
})
|
||||
)
|
||||
.filter((group) => {
|
||||
return group._count >= 6;
|
||||
.filter(({ _count }) => {
|
||||
return _count >= 6;
|
||||
})
|
||||
.map((group) => {
|
||||
return group.symbol;
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
});
|
||||
}
|
||||
|
||||
private async getCurrencies7D(): Promise<IDataGatheringItem[]> {
|
||||
const assetProfileIdentifiersWithCompleteMarketData =
|
||||
await this.getAssetProfileIdentifiersWithCompleteMarketData();
|
||||
|
||||
return this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.filter(({ dataSource, symbol }) => {
|
||||
return !assetProfileIdentifiersWithCompleteMarketData.some((item) => {
|
||||
return item.dataSource === dataSource && item.symbol === symbol;
|
||||
});
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: subDays(resetHours(new Date()), 7)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private getEarliestDate(aStartDate: Date) {
|
||||
return min([aStartDate, subYears(new Date(), 10)]);
|
||||
}
|
||||
|
||||
private async getSymbols7D({
|
||||
withUserSubscription = false
|
||||
}: {
|
||||
withUserSubscription?: boolean;
|
||||
}): Promise<IDataGatheringItem[]> {
|
||||
const symbolProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesByUserSubscription({
|
||||
withUserSubscription
|
||||
});
|
||||
|
||||
const symbolProfilesToGather = symbolProfiles
|
||||
const assetProfileIdentifiersWithCompleteMarketData =
|
||||
await this.getAssetProfileIdentifiersWithCompleteMarketData();
|
||||
|
||||
return symbolProfiles
|
||||
.filter(({ dataSource, scraperConfiguration, symbol }) => {
|
||||
const manualDataSourceWithScraperConfiguration =
|
||||
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
|
||||
|
||||
return (
|
||||
!symbolsWithCompleteMarketData.includes(symbol) &&
|
||||
!assetProfileIdentifiersWithCompleteMarketData.some((item) => {
|
||||
return item.dataSource === dataSource && item.symbol === symbol;
|
||||
}) &&
|
||||
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)
|
||||
);
|
||||
})
|
||||
.map((symbolProfile) => {
|
||||
return {
|
||||
...symbolProfile,
|
||||
date: startDate
|
||||
date: subDays(resetHours(new Date()), 7)
|
||||
};
|
||||
});
|
||||
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.filter(({ symbol }) => {
|
||||
return !symbolsWithCompleteMarketData.includes(symbol);
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
|
||||
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||
|
@ -14,8 +14,13 @@ import {
|
||||
DERIVED_CURRENCIES,
|
||||
PROPERTY_DATA_SOURCE_MAPPING
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getCurrencyFromSymbol,
|
||||
getStartOfUtcDate,
|
||||
isDerivedCurrency
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
@ -70,7 +75,7 @@ export class DataProviderService {
|
||||
return false;
|
||||
}
|
||||
|
||||
public async getAssetProfiles(items: UniqueAsset[]): Promise<{
|
||||
public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{
|
||||
[symbol: string]: Partial<SymbolProfile>;
|
||||
}> {
|
||||
const response: {
|
||||
@ -168,7 +173,7 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aItems: UniqueAsset[],
|
||||
aItems: AssetProfileIdentifier[],
|
||||
aGranularity: Granularity = 'month',
|
||||
from: Date,
|
||||
to: Date
|
||||
@ -238,7 +243,7 @@ export class DataProviderService {
|
||||
from,
|
||||
to
|
||||
}: {
|
||||
dataGatheringItems: UniqueAsset[];
|
||||
dataGatheringItems: AssetProfileIdentifier[];
|
||||
from: Date;
|
||||
to: Date;
|
||||
}): Promise<{
|
||||
@ -345,7 +350,7 @@ export class DataProviderService {
|
||||
useCache = true,
|
||||
user
|
||||
}: {
|
||||
items: UniqueAsset[];
|
||||
items: AssetProfileIdentifier[];
|
||||
requestTimeout?: number;
|
||||
useCache?: boolean;
|
||||
user?: UserWithSettings;
|
||||
@ -371,7 +376,7 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
// Get items from cache
|
||||
const itemsToFetch: UniqueAsset[] = [];
|
||||
const itemsToFetch: AssetProfileIdentifier[] = [];
|
||||
|
||||
for (const { dataSource, symbol } of items) {
|
||||
if (useCache) {
|
||||
@ -423,13 +428,18 @@ export class DataProviderService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
const symbols = dataGatheringItems
|
||||
.filter(({ symbol }) => {
|
||||
return !isDerivedCurrency(getCurrencyFromSymbol(symbol));
|
||||
})
|
||||
.map(({ symbol }) => {
|
||||
return symbol;
|
||||
});
|
||||
|
||||
const maximumNumberOfSymbolsPerRequest =
|
||||
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
|
||||
Number.MAX_SAFE_INTEGER;
|
||||
|
||||
for (
|
||||
let i = 0;
|
||||
i < symbols.length;
|
||||
@ -623,7 +633,7 @@ export class DataProviderService {
|
||||
dataGatheringItems
|
||||
}: {
|
||||
currency: string;
|
||||
dataGatheringItems: UniqueAsset[];
|
||||
dataGatheringItems: AssetProfileIdentifier[];
|
||||
}) {
|
||||
return dataGatheringItems.some(({ dataSource, symbol }) => {
|
||||
return (
|
||||
|
@ -361,13 +361,13 @@ export class ExchangeRateDataService {
|
||||
const symbol = `${currencyFrom}${currencyTo}`;
|
||||
|
||||
const marketData = await this.marketDataService.getRange({
|
||||
dateQuery: { gte: startDate, lt: endDate },
|
||||
uniqueAssets: [
|
||||
assetProfileIdentifiers: [
|
||||
{
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
]
|
||||
],
|
||||
dateQuery: { gte: startDate, lt: endDate }
|
||||
});
|
||||
|
||||
if (marketData?.length > 0) {
|
||||
@ -392,13 +392,13 @@ export class ExchangeRateDataService {
|
||||
}
|
||||
} else {
|
||||
const marketData = await this.marketDataService.getRange({
|
||||
dateQuery: { gte: startDate, lt: endDate },
|
||||
uniqueAssets: [
|
||||
assetProfileIdentifiers: [
|
||||
{
|
||||
dataSource,
|
||||
symbol: `${DEFAULT_CURRENCY}${currencyFrom}`
|
||||
}
|
||||
]
|
||||
],
|
||||
dateQuery: { gte: startDate, lt: endDate }
|
||||
});
|
||||
|
||||
for (const { date, marketPrice } of marketData) {
|
||||
@ -415,16 +415,16 @@ export class ExchangeRateDataService {
|
||||
}
|
||||
} else {
|
||||
const marketData = await this.marketDataService.getRange({
|
||||
dateQuery: {
|
||||
gte: startDate,
|
||||
lt: endDate
|
||||
},
|
||||
uniqueAssets: [
|
||||
assetProfileIdentifiers: [
|
||||
{
|
||||
dataSource,
|
||||
symbol: `${DEFAULT_CURRENCY}${currencyTo}`
|
||||
}
|
||||
]
|
||||
],
|
||||
dateQuery: {
|
||||
gte: startDate,
|
||||
lt: endDate
|
||||
}
|
||||
});
|
||||
|
||||
for (const { date, marketPrice } of marketData) {
|
||||
|
@ -1,4 +1,7 @@
|
||||
import { DataProviderInfo, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
DataProviderInfo
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { MarketState } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
@ -34,6 +37,6 @@ export interface IDataProviderResponse {
|
||||
marketState: MarketState;
|
||||
}
|
||||
|
||||
export interface IDataGatheringItem extends UniqueAsset {
|
||||
export interface IDataGatheringItem extends AssetProfileIdentifier {
|
||||
date?: Date;
|
||||
}
|
||||
|
@ -3,7 +3,7 @@ import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.i
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
@ -17,7 +17,7 @@ import {
|
||||
export class MarketDataService {
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async deleteMany({ dataSource, symbol }: UniqueAsset) {
|
||||
public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
return this.prismaService.marketData.deleteMany({
|
||||
where: {
|
||||
dataSource,
|
||||
@ -40,7 +40,7 @@ export class MarketDataService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getMax({ dataSource, symbol }: UniqueAsset) {
|
||||
public async getMax({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
return this.prismaService.marketData.findFirst({
|
||||
select: {
|
||||
date: true,
|
||||
@ -59,11 +59,11 @@ export class MarketDataService {
|
||||
}
|
||||
|
||||
public async getRange({
|
||||
dateQuery,
|
||||
uniqueAssets
|
||||
assetProfileIdentifiers,
|
||||
dateQuery
|
||||
}: {
|
||||
assetProfileIdentifiers: AssetProfileIdentifier[];
|
||||
dateQuery: DateQuery;
|
||||
uniqueAssets: UniqueAsset[];
|
||||
}): Promise<MarketData[]> {
|
||||
return this.prismaService.marketData.findMany({
|
||||
orderBy: [
|
||||
@ -76,13 +76,13 @@ export class MarketDataService {
|
||||
],
|
||||
where: {
|
||||
dataSource: {
|
||||
in: uniqueAssets.map(({ dataSource }) => {
|
||||
in: assetProfileIdentifiers.map(({ dataSource }) => {
|
||||
return dataSource;
|
||||
})
|
||||
},
|
||||
date: dateQuery,
|
||||
symbol: {
|
||||
in: uniqueAssets.map(({ symbol }) => {
|
||||
in: assetProfileIdentifiers.map(({ symbol }) => {
|
||||
return symbol;
|
||||
})
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
EnhancedSymbolProfile,
|
||||
Holding,
|
||||
ScraperConfiguration,
|
||||
UniqueAsset
|
||||
ScraperConfiguration
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
@ -23,7 +23,7 @@ export class SymbolProfileService {
|
||||
return this.prismaService.symbolProfile.create({ data: assetProfile });
|
||||
}
|
||||
|
||||
public async delete({ dataSource, symbol }: UniqueAsset) {
|
||||
public async delete({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
return this.prismaService.symbolProfile.delete({
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
});
|
||||
@ -36,7 +36,7 @@ export class SymbolProfileService {
|
||||
}
|
||||
|
||||
public async getSymbolProfiles(
|
||||
aUniqueAssets: UniqueAsset[]
|
||||
aAssetProfileIdentifiers: AssetProfileIdentifier[]
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prismaService.symbolProfile
|
||||
.findMany({
|
||||
@ -54,7 +54,7 @@ export class SymbolProfileService {
|
||||
SymbolProfileOverrides: true
|
||||
},
|
||||
where: {
|
||||
OR: aUniqueAssets.map(({ dataSource, symbol }) => {
|
||||
OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol
|
||||
@ -91,6 +91,40 @@ export class SymbolProfileService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getSymbolProfilesByUserSubscription({
|
||||
withUserSubscription = false
|
||||
}: {
|
||||
withUserSubscription?: boolean;
|
||||
}) {
|
||||
return this.prismaService.symbolProfile.findMany({
|
||||
include: {
|
||||
Order: {
|
||||
include: {
|
||||
User: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
where: {
|
||||
Order: withUserSubscription
|
||||
? {
|
||||
some: {
|
||||
User: {
|
||||
Subscription: { some: { expiresAt: { gt: new Date() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
: {
|
||||
every: {
|
||||
User: {
|
||||
Subscription: { none: { expiresAt: { gt: new Date() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public updateSymbolProfile({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
@ -106,7 +140,7 @@ export class SymbolProfileService {
|
||||
symbolMapping,
|
||||
SymbolProfileOverrides,
|
||||
url
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
}: AssetProfileIdentifier & Prisma.SymbolProfileUpdateInput) {
|
||||
return this.prismaService.symbolProfile.update({
|
||||
data: {
|
||||
assetClass,
|
||||
|
@ -36,6 +36,10 @@
|
||||
"ngswConfigPath": "apps/client/ngsw-config.json"
|
||||
},
|
||||
"configurations": {
|
||||
"development-ca": {
|
||||
"baseHref": "/ca/",
|
||||
"localize": ["ca"]
|
||||
},
|
||||
"development-de": {
|
||||
"baseHref": "/de/",
|
||||
"localize": ["de"]
|
||||
@ -212,6 +216,7 @@
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": [
|
||||
"messages.ca.xlf",
|
||||
"messages.de.xlf",
|
||||
"messages.es.xlf",
|
||||
"messages.fr.xlf",
|
||||
@ -240,6 +245,10 @@
|
||||
},
|
||||
"i18n": {
|
||||
"locales": {
|
||||
"ca": {
|
||||
"baseHref": "/ca/",
|
||||
"translation": "apps/client/src/locales/messages.ca.xlf"
|
||||
},
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
|
@ -145,6 +145,11 @@
|
||||
/></a>
|
||||
</li>
|
||||
<li> </li>
|
||||
<!--
|
||||
<li>
|
||||
<a href="../ca" title="Ghostfolio en català">Català</a>
|
||||
</li>
|
||||
-->
|
||||
<li>
|
||||
<a href="../de" title="Ghostfolio in Deutsch">Deutsch</a>
|
||||
</li>
|
||||
|
@ -255,10 +255,18 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
colorScheme: this.user?.settings?.colorScheme,
|
||||
deviceType: this.deviceType,
|
||||
hasImpersonationId: this.hasImpersonationId,
|
||||
hasPermissionToCreateOrder:
|
||||
!this.hasImpersonationId &&
|
||||
hasPermission(this.user?.permissions, permissions.createOrder) &&
|
||||
!this.user?.settings?.isRestrictedView,
|
||||
hasPermissionToReportDataGlitch: hasPermission(
|
||||
this.user?.permissions,
|
||||
permissions.reportDataGlitch
|
||||
),
|
||||
hasPermissionToUpdateOrder:
|
||||
!this.hasImpersonationId &&
|
||||
hasPermission(this.user?.permissions, permissions.updateOrder) &&
|
||||
!this.user?.settings?.isRestrictedView,
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
|
@ -23,6 +23,7 @@ import {
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { Sort, SortDirection } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Router } from '@angular/router';
|
||||
import { Big } from 'big.js';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { isNumber } from 'lodash';
|
||||
@ -66,6 +67,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
@Inject(MAT_DIALOG_DATA) public data: AccountDetailDialogParams,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<AccountDetailDialog>,
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.userService.stateChanged
|
||||
@ -92,6 +94,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
this.fetchPortfolioPerformance();
|
||||
}
|
||||
|
||||
public onCloneActivity(aActivity: Activity) {
|
||||
this.router.navigate(['/portfolio', 'activities'], {
|
||||
queryParams: { activityId: aActivity.id, createDialog: true }
|
||||
});
|
||||
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onClose() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
@ -147,6 +157,14 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
this.fetchActivities();
|
||||
}
|
||||
|
||||
public onUpdateActivity(aActivity: Activity) {
|
||||
this.router.navigate(['/portfolio', 'activities'], {
|
||||
queryParams: { activityId: aActivity.id, editDialog: true }
|
||||
});
|
||||
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
private fetchAccount() {
|
||||
this.dataService
|
||||
.fetchAccount(this.data.accountId)
|
||||
|
@ -101,10 +101,17 @@
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="false"
|
||||
[showActions]="
|
||||
!data.hasImpersonationId &&
|
||||
data.hasPermissionToCreateOrder &&
|
||||
user?.settings?.isExperimentalFeatures &&
|
||||
!user?.settings?.isRestrictedView
|
||||
"
|
||||
[sortColumn]="sortColumn"
|
||||
[sortDirection]="sortDirection"
|
||||
[totalItems]="totalItems"
|
||||
(activityToClone)="onCloneActivity($event)"
|
||||
(activityToUpdate)="onUpdateActivity($event)"
|
||||
(export)="onExport()"
|
||||
(sortChanged)="onSortChanged($event)"
|
||||
/>
|
||||
|
@ -2,4 +2,5 @@ export interface AccountDetailDialogParams {
|
||||
accountId: string;
|
||||
deviceType: string;
|
||||
hasImpersonationId: boolean;
|
||||
hasPermissionToCreateOrder: boolean;
|
||||
}
|
||||
|
@ -6,8 +6,14 @@ import {
|
||||
ghostfolioScraperApiSymbolPrefix
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getDateFormatString } from '@ghostfolio/common/helper';
|
||||
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
Filter,
|
||||
InfoItem,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
@ -97,22 +103,11 @@ export class AdminMarketDataComponent
|
||||
new MatTableDataSource();
|
||||
public defaultDateFormat: string;
|
||||
public deviceType: string;
|
||||
public displayedColumns = [
|
||||
'select',
|
||||
'nameWithSymbol',
|
||||
'dataSource',
|
||||
'assetClass',
|
||||
'assetSubClass',
|
||||
'date',
|
||||
'activitiesCount',
|
||||
'marketDataItemCount',
|
||||
'sectorsCount',
|
||||
'countriesCount',
|
||||
'comment',
|
||||
'actions'
|
||||
];
|
||||
public displayedColumns: string[] = [];
|
||||
public filters$ = new Subject<Filter[]>();
|
||||
public ghostfolioScraperApiSymbolPrefix = ghostfolioScraperApiSymbolPrefix;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public info: InfoItem;
|
||||
public isLoading = false;
|
||||
public isUUID = isUUID;
|
||||
public placeholder = '';
|
||||
@ -134,6 +129,33 @@ export class AdminMarketDataComponent
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.info = this.dataService.fetchInfo();
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
this.displayedColumns = [
|
||||
'select',
|
||||
'nameWithSymbol',
|
||||
'dataSource',
|
||||
'assetClass',
|
||||
'assetSubClass',
|
||||
'date',
|
||||
'activitiesCount',
|
||||
'marketDataItemCount',
|
||||
'sectorsCount',
|
||||
'countriesCount'
|
||||
];
|
||||
|
||||
if (this.hasPermissionForSubscription) {
|
||||
this.displayedColumns.push('isUsedByUsersWithSubscription');
|
||||
}
|
||||
|
||||
this.displayedColumns.push('comment');
|
||||
this.displayedColumns.push('actions');
|
||||
|
||||
this.route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
@ -203,7 +225,7 @@ export class AdminMarketDataComponent
|
||||
});
|
||||
}
|
||||
|
||||
public onDeleteAssetProfile({ dataSource, symbol }: UniqueAsset) {
|
||||
public onDeleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@ -244,21 +266,27 @@ export class AdminMarketDataComponent
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
public onGatherProfileDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: AssetProfileIdentifier) {
|
||||
this.adminService
|
||||
.gatherProfileDataBySymbol({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
this.adminService
|
||||
.gatherSymbol({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onOpenAssetProfileDialog({ dataSource, symbol }: UniqueAsset) {
|
||||
public onOpenAssetProfileDialog({
|
||||
dataSource,
|
||||
symbol
|
||||
}: AssetProfileIdentifier) {
|
||||
this.router.navigate([], {
|
||||
queryParams: {
|
||||
dataSource,
|
||||
|
@ -144,6 +144,15 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="isUsedByUsersWithSubscription">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@if (element.isUsedByUsersWithSubscription) {
|
||||
<gf-premium-indicator [enableLink]="false" />
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="comment">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
|
||||
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
@ -24,6 +25,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
|
||||
GfActivitiesFilterComponent,
|
||||
GfAssetProfileDialogModule,
|
||||
GfCreateAssetProfileDialogModule,
|
||||
GfPremiumIndicatorComponent,
|
||||
GfSymbolModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
|
@ -2,8 +2,8 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
|
||||
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminMarketDataItem,
|
||||
UniqueAsset
|
||||
AssetProfileIdentifier,
|
||||
AdminMarketDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
@ -13,7 +13,7 @@ import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs';
|
||||
export class AdminMarketDataService {
|
||||
public constructor(private adminService: AdminService) {}
|
||||
|
||||
public deleteAssetProfile({ dataSource, symbol }: UniqueAsset) {
|
||||
public deleteAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
const confirmation = confirm(
|
||||
$localize`Do you really want to delete this asset profile?`
|
||||
);
|
||||
@ -29,15 +29,19 @@ export class AdminMarketDataService {
|
||||
}
|
||||
}
|
||||
|
||||
public deleteAssetProfiles(uniqueAssets: UniqueAsset[]) {
|
||||
public deleteAssetProfiles(
|
||||
aAssetProfileIdentifiers: AssetProfileIdentifier[]
|
||||
) {
|
||||
const confirmation = confirm(
|
||||
$localize`Do you really want to delete these profiles?`
|
||||
);
|
||||
|
||||
if (confirmation) {
|
||||
const deleteRequests = uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||
});
|
||||
const deleteRequests = aAssetProfileIdentifiers.map(
|
||||
({ dataSource, symbol }) => {
|
||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||
}
|
||||
);
|
||||
|
||||
forkJoin(deleteRequests)
|
||||
.pipe(
|
||||
|
@ -8,7 +8,7 @@ import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminMarketDataDetails,
|
||||
UniqueAsset
|
||||
AssetProfileIdentifier
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
|
||||
@ -175,20 +175,23 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
public onDeleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
|
||||
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
public onGatherProfileDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: AssetProfileIdentifier) {
|
||||
this.adminService
|
||||
.gatherProfileDataBySymbol({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public onGatherSymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
this.adminService
|
||||
.gatherSymbol({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -242,7 +245,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public onSetBenchmark({ dataSource, symbol }: UniqueAsset) {
|
||||
public onSetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
this.dataService
|
||||
.postBenchmark({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -342,7 +345,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onUnsetBenchmark({ dataSource, symbol }: UniqueAsset) {
|
||||
public onUnsetBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
this.dataService
|
||||
.deleteBenchmark({ dataSource, symbol })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
|
@ -12,11 +12,7 @@
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Count</div>
|
||||
<div class="w-50">
|
||||
<gf-value
|
||||
[locale]="user?.settings?.locale"
|
||||
[precision]="0"
|
||||
[value]="userCount"
|
||||
/>
|
||||
<gf-value [locale]="user?.settings?.locale" [value]="userCount" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
@ -24,7 +20,6 @@
|
||||
<div class="w-50">
|
||||
<gf-value
|
||||
[locale]="user?.settings?.locale"
|
||||
[precision]="0"
|
||||
[value]="transactionCount"
|
||||
/>
|
||||
@if (transactionCount && userCount) {
|
||||
|
@ -4,6 +4,7 @@ import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-foote
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
@ -18,16 +19,24 @@ import { GfLineChartComponent } from '@ghostfolio/ui/line-chart';
|
||||
import { GfPortfolioProportionChartComponent } from '@ghostfolio/ui/portfolio-proportion-chart';
|
||||
import { GfValueComponent } from '@ghostfolio/ui/value';
|
||||
|
||||
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
OnInit
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormGroup } from '@angular/forms';
|
||||
import {
|
||||
MatAutocompleteModule,
|
||||
MatAutocompleteSelectedEvent
|
||||
} from '@angular/material/autocomplete';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import {
|
||||
@ -35,14 +44,16 @@ import {
|
||||
MatDialogModule,
|
||||
MatDialogRef
|
||||
} from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { SortDirection } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { Router } from '@angular/router';
|
||||
import { Account, Tag } from '@prisma/client';
|
||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { Observable, of, Subject } from 'rxjs';
|
||||
import { map, startWith, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@ -59,9 +70,11 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
GfLineChartComponent,
|
||||
GfPortfolioProportionChartComponent,
|
||||
GfValueComponent,
|
||||
MatAutocompleteModule,
|
||||
MatButtonModule,
|
||||
MatChipsModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatTabsModule,
|
||||
NgxSkeletonLoaderModule
|
||||
],
|
||||
@ -72,6 +85,9 @@ import { HoldingDetailDialogParams } from './interfaces/interfaces';
|
||||
templateUrl: 'holding-detail-dialog.html'
|
||||
})
|
||||
export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
|
||||
|
||||
public activityForm: FormGroup;
|
||||
public accounts: Account[];
|
||||
public activities: Activity[];
|
||||
public assetClass: string;
|
||||
@ -84,28 +100,35 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
public dataProviderInfo: DataProviderInfo;
|
||||
public dataSource: MatTableDataSource<Activity>;
|
||||
public dividendInBaseCurrency: number;
|
||||
public dividendInBaseCurrencyPrecision = 2;
|
||||
public dividendYieldPercentWithCurrencyEffect: number;
|
||||
public feeInBaseCurrency: number;
|
||||
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
||||
public firstBuyDate: string;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public investment: number;
|
||||
public investmentPrecision = 2;
|
||||
public marketPrice: number;
|
||||
public maxPrice: number;
|
||||
public minPrice: number;
|
||||
public netPerformance: number;
|
||||
public netPerformancePrecision = 2;
|
||||
public netPerformancePercent: number;
|
||||
public netPerformancePercentWithCurrencyEffect: number;
|
||||
public netPerformanceWithCurrencyEffect: number;
|
||||
public netPerformanceWithCurrencyEffectPrecision = 2;
|
||||
public quantity: number;
|
||||
public quantityPrecision = 2;
|
||||
public reportDataGlitchMail: string;
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||
public sortColumn = 'date';
|
||||
public sortDirection: SortDirection = 'desc';
|
||||
public SymbolProfile: EnhancedSymbolProfile;
|
||||
public tags: Tag[];
|
||||
public tagsAvailable: Tag[];
|
||||
public totalItems: number;
|
||||
public transactionCount: number;
|
||||
public user: User;
|
||||
@ -118,10 +141,39 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<GfHoldingDetailDialogComponent>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: HoldingDetailDialogParams,
|
||||
private formBuilder: FormBuilder,
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
const { tags } = this.dataService.fetchInfo();
|
||||
|
||||
this.activityForm = this.formBuilder.group({
|
||||
tags: <string[]>[]
|
||||
});
|
||||
|
||||
this.tagsAvailable = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
name: translate(name)
|
||||
};
|
||||
});
|
||||
|
||||
this.activityForm
|
||||
.get('tags')
|
||||
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((tags) => {
|
||||
this.dataService
|
||||
.putHoldingTags({
|
||||
tags,
|
||||
dataSource: this.data.dataSource,
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchHoldingDetail({
|
||||
dataSource: this.data.dataSource,
|
||||
@ -161,10 +213,20 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
this.dataProviderInfo = dataProviderInfo;
|
||||
this.dataSource = new MatTableDataSource(orders.reverse());
|
||||
this.dividendInBaseCurrency = dividendInBaseCurrency;
|
||||
|
||||
if (
|
||||
this.data.deviceType === 'mobile' &&
|
||||
this.dividendInBaseCurrency >= NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.dividendInBaseCurrencyPrecision = 0;
|
||||
}
|
||||
|
||||
this.dividendYieldPercentWithCurrencyEffect =
|
||||
dividendYieldPercentWithCurrencyEffect;
|
||||
|
||||
this.feeInBaseCurrency = feeInBaseCurrency;
|
||||
this.firstBuyDate = firstBuyDate;
|
||||
|
||||
this.historicalDataItems = historicalData.map(
|
||||
({ averagePrice, date, marketPrice }) => {
|
||||
this.benchmarkDataItems.push({
|
||||
@ -178,26 +240,82 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
this.investment = investment;
|
||||
|
||||
if (
|
||||
this.data.deviceType === 'mobile' &&
|
||||
this.investment >= NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.investmentPrecision = 0;
|
||||
}
|
||||
|
||||
this.marketPrice = marketPrice;
|
||||
this.maxPrice = maxPrice;
|
||||
this.minPrice = minPrice;
|
||||
this.netPerformance = netPerformance;
|
||||
|
||||
if (
|
||||
this.data.deviceType === 'mobile' &&
|
||||
this.netPerformance >= NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.netPerformancePrecision = 0;
|
||||
}
|
||||
|
||||
this.netPerformancePercent = netPerformancePercent;
|
||||
|
||||
this.netPerformancePercentWithCurrencyEffect =
|
||||
netPerformancePercentWithCurrencyEffect;
|
||||
|
||||
this.netPerformanceWithCurrencyEffect =
|
||||
netPerformanceWithCurrencyEffect;
|
||||
|
||||
if (
|
||||
this.data.deviceType === 'mobile' &&
|
||||
this.netPerformanceWithCurrencyEffect >=
|
||||
NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.netPerformanceWithCurrencyEffectPrecision = 0;
|
||||
}
|
||||
|
||||
this.quantity = quantity;
|
||||
|
||||
if (Number.isInteger(this.quantity)) {
|
||||
this.quantityPrecision = 0;
|
||||
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
|
||||
if (this.quantity < 1) {
|
||||
this.quantityPrecision = 7;
|
||||
} else if (this.quantity < 1000) {
|
||||
this.quantityPrecision = 5;
|
||||
} else if (this.quantity >= 10000000) {
|
||||
this.quantityPrecision = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
|
||||
this.sectors = {};
|
||||
this.SymbolProfile = SymbolProfile;
|
||||
|
||||
this.tags = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
name: translate(name)
|
||||
};
|
||||
});
|
||||
|
||||
this.activityForm.setValue({ tags: this.tags }, { emitEvent: false });
|
||||
|
||||
this.filteredTagsObservable = this.activityForm.controls[
|
||||
'tags'
|
||||
].valueChanges.pipe(
|
||||
startWith(this.activityForm.get('tags').value),
|
||||
map((aTags: Tag[] | null) => {
|
||||
return aTags
|
||||
? this.filterTags(aTags)
|
||||
: this.tagsAvailable.slice();
|
||||
})
|
||||
);
|
||||
|
||||
this.transactionCount = transactionCount;
|
||||
this.totalItems = transactionCount;
|
||||
this.value = value;
|
||||
@ -282,18 +400,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
);
|
||||
|
||||
if (Number.isInteger(this.quantity)) {
|
||||
this.quantityPrecision = 0;
|
||||
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
|
||||
if (this.quantity < 1) {
|
||||
this.quantityPrecision = 7;
|
||||
} else if (this.quantity < 1000) {
|
||||
this.quantityPrecision = 5;
|
||||
} else if (this.quantity > 10000000) {
|
||||
this.quantityPrecision = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
);
|
||||
@ -309,6 +415,25 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onAddTag(event: MatAutocompleteSelectedEvent) {
|
||||
this.activityForm.get('tags').setValue([
|
||||
...(this.activityForm.get('tags').value ?? []),
|
||||
this.tagsAvailable.find(({ id }) => {
|
||||
return id === event.option.value;
|
||||
})
|
||||
]);
|
||||
|
||||
this.tagInput.nativeElement.value = '';
|
||||
}
|
||||
|
||||
public onCloneActivity(aActivity: Activity) {
|
||||
this.router.navigate(['/portfolio', 'activities'], {
|
||||
queryParams: { activityId: aActivity.id, createDialog: true }
|
||||
});
|
||||
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onClose() {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
@ -333,8 +458,34 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onRemoveTag(aTag: Tag) {
|
||||
this.activityForm.get('tags').setValue(
|
||||
this.activityForm.get('tags').value.filter(({ id }) => {
|
||||
return id !== aTag.id;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public onUpdateActivity(aActivity: Activity) {
|
||||
this.router.navigate(['/portfolio', 'activities'], {
|
||||
queryParams: { activityId: aActivity.id, editDialog: true }
|
||||
});
|
||||
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private filterTags(aTags: Tag[]) {
|
||||
const tagIds = aTags.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
|
||||
return this.tagsAvailable.filter(({ id }) => {
|
||||
return !tagIds.includes(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -47,6 +47,7 @@
|
||||
[colorizeSign]="true"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[precision]="netPerformanceWithCurrencyEffectPrecision"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="netPerformanceWithCurrencyEffect"
|
||||
>Change with currency effect</gf-value
|
||||
@ -58,6 +59,7 @@
|
||||
[colorizeSign]="true"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[precision]="netPerformancePrecision"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="netPerformance"
|
||||
>Change</gf-value
|
||||
@ -160,6 +162,7 @@
|
||||
size="medium"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[precision]="investmentPrecision"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="investment"
|
||||
>Investment</gf-value
|
||||
@ -172,6 +175,7 @@
|
||||
size="medium"
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[precision]="dividendInBaseCurrencyPrecision"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="dividendInBaseCurrency"
|
||||
>Dividend</gf-value
|
||||
@ -321,7 +325,7 @@
|
||||
|
||||
<mat-tab-group
|
||||
animationDuration="0"
|
||||
class="mb-3"
|
||||
class="mb-5"
|
||||
[mat-stretch-tabs]="false"
|
||||
[ngClass]="{ 'd-none': !activities?.length }"
|
||||
>
|
||||
@ -342,12 +346,19 @@
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="data.locale"
|
||||
[showActions]="false"
|
||||
[showActions]="
|
||||
!data.hasImpersonationId &&
|
||||
data.hasPermissionToCreateOrder &&
|
||||
user?.settings?.isExperimentalFeatures &&
|
||||
!user?.settings?.isRestrictedView
|
||||
"
|
||||
[showNameColumn]="false"
|
||||
[sortColumn]="sortColumn"
|
||||
[sortDirection]="sortDirection"
|
||||
[sortDisabled]="true"
|
||||
[totalItems]="totalItems"
|
||||
(activityToClone)="onCloneActivity($event)"
|
||||
(activityToUpdate)="onUpdateActivity($event)"
|
||||
(export)="onExport()"
|
||||
/>
|
||||
</mat-tab>
|
||||
@ -371,7 +382,49 @@
|
||||
</mat-tab>
|
||||
</mat-tab-group>
|
||||
|
||||
@if (tags?.length > 0) {
|
||||
<div
|
||||
class="row"
|
||||
[ngClass]="{
|
||||
'd-none': !data.hasPermissionToUpdateOrder
|
||||
}"
|
||||
>
|
||||
<div class="col">
|
||||
<mat-form-field appearance="outline" class="w-100 without-hint">
|
||||
<mat-label i18n>Tags</mat-label>
|
||||
<mat-chip-grid #tagsChipList>
|
||||
@for (tag of activityForm.get('tags')?.value; track tag.id) {
|
||||
<mat-chip-row
|
||||
matChipRemove
|
||||
[removable]="true"
|
||||
(removed)="onRemoveTag(tag)"
|
||||
>
|
||||
{{ tag.name }}
|
||||
<ion-icon class="ml-2" matPrefix name="close-outline" />
|
||||
</mat-chip-row>
|
||||
}
|
||||
<input
|
||||
#tagInput
|
||||
name="close-outline"
|
||||
[matAutocomplete]="autocompleteTags"
|
||||
[matChipInputFor]="tagsChipList"
|
||||
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||
/>
|
||||
</mat-chip-grid>
|
||||
<mat-autocomplete
|
||||
#autocompleteTags="matAutocomplete"
|
||||
(optionSelected)="onAddTag($event)"
|
||||
>
|
||||
@for (tag of filteredTagsObservable | async; track tag.id) {
|
||||
<mat-option [value]="tag.id">
|
||||
{{ tag.name }}
|
||||
</mat-option>
|
||||
}
|
||||
</mat-autocomplete>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!data.hasPermissionToUpdateOrder && tagsAvailable?.length > 0) {
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="h5" i18n>Tags</div>
|
||||
|
@ -8,7 +8,9 @@ export interface HoldingDetailDialogParams {
|
||||
dataSource: DataSource;
|
||||
deviceType: string;
|
||||
hasImpersonationId: boolean;
|
||||
hasPermissionToCreateOrder: boolean;
|
||||
hasPermissionToReportDataGlitch: boolean;
|
||||
hasPermissionToUpdateOrder: boolean;
|
||||
locale: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -1,14 +1,24 @@
|
||||
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 { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
PortfolioPosition,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { HoldingType, ToggleOption } from '@ghostfolio/common/types';
|
||||
import {
|
||||
HoldingType,
|
||||
HoldingsViewMode,
|
||||
ToggleOption
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
import { skip, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-home-holdings',
|
||||
@ -16,8 +26,11 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './home-holdings.html'
|
||||
})
|
||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
public static DEFAULT_HOLDINGS_VIEW_MODE: HoldingsViewMode = 'TABLE';
|
||||
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToAccessHoldingsChart: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public holdings: PortfolioPosition[];
|
||||
public holdingType: HoldingType = 'ACTIVE';
|
||||
@ -26,6 +39,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
{ label: $localize`Closed`, value: 'CLOSED' }
|
||||
];
|
||||
public user: User;
|
||||
public viewModeFormControl = new FormControl<HoldingsViewMode>(
|
||||
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
|
||||
);
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -34,6 +50,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private router: Router,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
@ -53,38 +70,50 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionToAccessHoldingsChart = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.accessHoldingsChart
|
||||
);
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
|
||||
this.holdings = undefined;
|
||||
|
||||
this.fetchHoldings()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ holdings }) => {
|
||||
this.holdings = holdings;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
this.initialize();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
||||
this.viewModeFormControl.valueChanges
|
||||
.pipe(
|
||||
// Skip inizialization: "new FormControl"
|
||||
skip(1),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe((holdingsViewMode) => {
|
||||
this.dataService
|
||||
.putUserSetting({ holdingsViewMode })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public onChangeHoldingType(aHoldingType: HoldingType) {
|
||||
this.holdingType = aHoldingType;
|
||||
|
||||
this.holdings = undefined;
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
this.fetchHoldings()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ holdings }) => {
|
||||
this.holdings = holdings;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
public onSymbolClicked({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
if (dataSource && symbol) {
|
||||
this.router.navigate([], {
|
||||
queryParams: { dataSource, symbol, holdingDetailDialog: true }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
@ -104,4 +133,38 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
range: this.user?.settings?.dateRange
|
||||
});
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.viewModeFormControl.disable();
|
||||
|
||||
if (
|
||||
this.hasPermissionToAccessHoldingsChart &&
|
||||
this.holdingType === 'ACTIVE'
|
||||
) {
|
||||
this.viewModeFormControl.enable({ emitEvent: false });
|
||||
|
||||
this.viewModeFormControl.setValue(
|
||||
this.deviceType === 'mobile'
|
||||
? HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE
|
||||
: this.user?.settings?.holdingsViewMode ||
|
||||
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
|
||||
{ emitEvent: false }
|
||||
);
|
||||
} else if (this.holdingType === 'CLOSED') {
|
||||
this.viewModeFormControl.setValue(
|
||||
HomeHoldingsComponent.DEFAULT_HOLDINGS_VIEW_MODE,
|
||||
{ emitEvent: false }
|
||||
);
|
||||
}
|
||||
|
||||
this.holdings = undefined;
|
||||
|
||||
this.fetchHoldings()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ holdings }) => {
|
||||
this.holdings = holdings;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -6,33 +6,62 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-toggle
|
||||
class="d-none d-lg-block"
|
||||
[defaultValue]="holdingType"
|
||||
[isLoading]="false"
|
||||
[options]="holdingTypeOptions"
|
||||
(change)="onChangeHoldingType($event.value)"
|
||||
/>
|
||||
</div>
|
||||
<gf-holdings-table
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
|
||||
[holdings]="holdings"
|
||||
[locale]="user?.settings?.locale"
|
||||
/>
|
||||
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
|
||||
<div class="text-center">
|
||||
<a
|
||||
class="mt-3"
|
||||
i18n
|
||||
mat-stroked-button
|
||||
[routerLink]="['/portfolio', 'activities']"
|
||||
>Manage Activities</a
|
||||
>
|
||||
<div class="d-flex">
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<div class="d-flex">
|
||||
<div class="d-none d-lg-block">
|
||||
<mat-button-toggle-group
|
||||
[formControl]="viewModeFormControl"
|
||||
[hideSingleSelectionIndicator]="true"
|
||||
>
|
||||
<mat-button-toggle i18n-title title="Table" value="TABLE">
|
||||
<ion-icon name="reorder-four-outline" />
|
||||
</mat-button-toggle>
|
||||
<mat-button-toggle i18n-title title="Chart" value="CHART">
|
||||
<ion-icon name="grid-outline" />
|
||||
</mat-button-toggle>
|
||||
</mat-button-toggle-group>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="align-items-center d-flex flex-grow-1 justify-content-end">
|
||||
<gf-toggle
|
||||
class="d-none d-lg-block"
|
||||
[defaultValue]="holdingType"
|
||||
[isLoading]="false"
|
||||
[options]="holdingTypeOptions"
|
||||
(change)="onChangeHoldingType($event.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@if (viewModeFormControl.value === 'CHART') {
|
||||
<gf-treemap-chart
|
||||
class="mt-3"
|
||||
cursor="pointer"
|
||||
[holdings]="holdings"
|
||||
(treemapChartClicked)="onSymbolClicked($event)"
|
||||
/>
|
||||
}
|
||||
<div [ngClass]="{ 'd-none': viewModeFormControl.value !== 'TABLE' }">
|
||||
<gf-holdings-table
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
|
||||
[holdings]="holdings"
|
||||
[locale]="user?.settings?.locale"
|
||||
/>
|
||||
@if (hasPermissionToCreateOrder && holdings?.length > 0) {
|
||||
<div class="text-center">
|
||||
<a
|
||||
class="mt-3"
|
||||
i18n
|
||||
mat-stroked-button
|
||||
[routerLink]="['/portfolio', 'activities']"
|
||||
>Manage Activities</a
|
||||
>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
import { GfHoldingsTableComponent } from '@ghostfolio/ui/holdings-table';
|
||||
import { GfTreemapChartComponent } from '@ghostfolio/ui/treemap-chart';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatButtonToggleModule } from '@angular/material/button-toggle';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { HomeHoldingsComponent } from './home-holdings.component';
|
||||
@ -12,9 +15,13 @@ import { HomeHoldingsComponent } from './home-holdings.component';
|
||||
declarations: [HomeHoldingsComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfHoldingsTableComponent,
|
||||
GfToggleModule,
|
||||
GfTreemapChartComponent,
|
||||
MatButtonModule,
|
||||
MatButtonToggleModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
|
@ -1,3 +1,9 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.mat-button-toggle-group {
|
||||
.mat-button-toggle-appearance-standard {
|
||||
--mat-standard-button-toggle-height: 1.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,10 +3,11 @@ import { LayoutService } from '@ghostfolio/client/core/layout.service';
|
||||
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 { NUMERICAL_PRECISION_THRESHOLD } from '@ghostfolio/common/config';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
LineChartItem,
|
||||
PortfolioPerformance,
|
||||
UniqueAsset,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
@ -25,7 +26,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
|
||||
public deviceType: string;
|
||||
public errors: UniqueAsset[];
|
||||
public errors: AssetProfileIdentifier[];
|
||||
public hasError: boolean;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
@ -34,6 +35,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
public isAllTimeLow: boolean;
|
||||
public isLoadingPerformance = true;
|
||||
public performance: PortfolioPerformance;
|
||||
public precision = 2;
|
||||
public showDetails = false;
|
||||
public unit: string;
|
||||
public user: User;
|
||||
@ -67,6 +69,12 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.showDetails =
|
||||
!this.user.settings.isRestrictedView &&
|
||||
this.user.settings.viewMode !== 'ZEN';
|
||||
|
||||
this.unit = this.showDetails ? this.user.settings.baseCurrency : '%';
|
||||
|
||||
this.impersonationStorageService
|
||||
.onChangeHasImpersonation()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -81,12 +89,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
.subscribe(() => {
|
||||
this.update();
|
||||
});
|
||||
|
||||
this.showDetails =
|
||||
!this.user.settings.isRestrictedView &&
|
||||
this.user.settings.viewMode !== 'ZEN';
|
||||
|
||||
this.unit = this.showDetails ? this.user.settings.baseCurrency : '%';
|
||||
}
|
||||
|
||||
public onChangeDateRange(dateRange: DateRange) {
|
||||
@ -134,6 +136,14 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
this.deviceType === 'mobile' &&
|
||||
this.performance.currentValueInBaseCurrency >=
|
||||
NUMERICAL_PRECISION_THRESHOLD
|
||||
) {
|
||||
this.precision = 0;
|
||||
}
|
||||
|
||||
this.isLoadingPerformance = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
@ -88,6 +88,7 @@
|
||||
[isLoading]="isLoadingPerformance"
|
||||
[locale]="user?.settings?.locale"
|
||||
[performance]="performance"
|
||||
[precision]="precision"
|
||||
[showDetails]="showDetails"
|
||||
[unit]="unit"
|
||||
/>
|
||||
|
@ -14,7 +14,6 @@ import {
|
||||
ElementRef,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { CountUp } from 'countup.js';
|
||||
@ -26,7 +25,7 @@ import { isNumber } from 'lodash';
|
||||
templateUrl: './portfolio-performance.component.html',
|
||||
styleUrls: ['./portfolio-performance.component.scss']
|
||||
})
|
||||
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
export class PortfolioPerformanceComponent implements OnChanges {
|
||||
@Input() deviceType: string;
|
||||
@Input() errors: ResponseError['errors'];
|
||||
@Input() isAllTimeHigh: boolean;
|
||||
@ -34,6 +33,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
@Input() isLoading: boolean;
|
||||
@Input() locale = getLocale();
|
||||
@Input() performance: PortfolioPerformance;
|
||||
@Input() precision: number;
|
||||
@Input() showDetails: boolean;
|
||||
@Input() unit: string;
|
||||
|
||||
@ -41,9 +41,9 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
|
||||
public constructor() {}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
public ngOnChanges() {
|
||||
this.precision = this.precision >= 0 ? this.precision : 2;
|
||||
|
||||
if (this.isLoading) {
|
||||
if (this.value?.nativeElement) {
|
||||
this.value.nativeElement.innerHTML = '';
|
||||
@ -52,11 +52,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
if (isNumber(this.performance?.currentValueInBaseCurrency)) {
|
||||
new CountUp('value', this.performance?.currentValueInBaseCurrency, {
|
||||
decimal: getNumberFormatDecimal(this.locale),
|
||||
decimalPlaces:
|
||||
this.deviceType === 'mobile' &&
|
||||
this.performance?.currentValueInBaseCurrency >= 100000
|
||||
? 0
|
||||
: 2,
|
||||
decimalPlaces: this.precision,
|
||||
duration: 1,
|
||||
separator: getNumberFormatGroup(this.locale)
|
||||
}).start();
|
||||
|
@ -47,6 +47,7 @@ export class UserAccountSettingsComponent implements OnDestroy, OnInit {
|
||||
public isWebAuthnEnabled: boolean;
|
||||
public language = document.documentElement.lang;
|
||||
public locales = [
|
||||
'ca',
|
||||
'de',
|
||||
'de-CH',
|
||||
'en-GB',
|
||||
|
@ -72,6 +72,14 @@
|
||||
<mat-option [value]="null" />
|
||||
<mat-option value="de">Deutsch</mat-option>
|
||||
<mat-option value="en">English</mat-option>
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<!--
|
||||
<mat-option value="ca"
|
||||
>Català (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
-->
|
||||
}
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<mat-option value="zh"
|
||||
>Chinese (<ng-container i18n>Community</ng-container
|
||||
@ -95,10 +103,12 @@
|
||||
>)</mat-option
|
||||
>
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<mat-option value="pl"
|
||||
>Polski (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
<!--
|
||||
<mat-option value="pl"
|
||||
>Polski (<ng-container i18n>Community</ng-container
|
||||
>)</mat-option
|
||||
>
|
||||
-->
|
||||
}
|
||||
<mat-option value="pt"
|
||||
>Português (<ng-container i18n>Community</ng-container
|
||||
@ -196,25 +206,23 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@if (hasPermissionToUpdateUserSettings) {
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
<div class="pr-1 w-50">
|
||||
<div i18n>Experimental Features</div>
|
||||
<div class="hint-text text-muted" i18n>
|
||||
Sneak peek at upcoming functionality
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-1 w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
hideIcon="true"
|
||||
[checked]="user.settings.isExperimentalFeatures"
|
||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||
(change)="onExperimentalFeaturesChange($event)"
|
||||
/>
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
<div class="pr-1 w-50">
|
||||
<div i18n>Experimental Features</div>
|
||||
<div class="hint-text text-muted" i18n>
|
||||
Sneak peek at upcoming functionality
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="pl-1 w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
hideIcon="true"
|
||||
[checked]="user.settings.isExperimentalFeatures"
|
||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||
(change)="onExperimentalFeaturesChange($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
<div class="pr-1 w-50">
|
||||
Ghostfolio <ng-container i18n>User ID</ng-container>
|
||||
|
@ -221,7 +221,11 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||
data: <AccountDetailDialogParams>{
|
||||
accountId: aAccountId,
|
||||
deviceType: this.deviceType,
|
||||
hasImpersonationId: this.hasImpersonationId
|
||||
hasImpersonationId: this.hasImpersonationId,
|
||||
hasPermissionToCreateOrder:
|
||||
!this.hasImpersonationId &&
|
||||
hasPermission(this.user?.permissions, permissions.createOrder) &&
|
||||
!this.user?.settings?.isRestrictedView
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
|
@ -242,9 +242,11 @@
|
||||
<h4 i18n>Multi-Language</h4>
|
||||
<p class="m-0">
|
||||
Use Ghostfolio in multiple languages: English,
|
||||
<!-- Chinese, -->Dutch, French, German, Italian,
|
||||
<!-- Polish, -->Portuguese, Spanish and Turkish are currently
|
||||
supported.
|
||||
<!--Català, -->
|
||||
<!-- Chinese, -->
|
||||
Dutch, French, German, Italian,
|
||||
<!-- Polish, -->
|
||||
Portuguese, Spanish and Turkish are currently supported.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
@ -186,6 +186,14 @@
|
||||
title="Sackgeld.com – Apps für ein höheres Sackgeld"
|
||||
></a>
|
||||
</div>
|
||||
<div class="align-items-center col-md-3 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-selfh-st mask"
|
||||
href="https://selfh.st"
|
||||
target="_blank"
|
||||
title="selfh.st — Self-hosted content and software"
|
||||
></a>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-sourceforge mask"
|
||||
|
@ -80,6 +80,11 @@
|
||||
mask-image: url('/assets/images/logo-sackgeld.png');
|
||||
}
|
||||
|
||||
&.logo-selfh-st {
|
||||
mask-image: url('/assets/images/logo-selfh-st.svg');
|
||||
max-height: 1.25rem;
|
||||
}
|
||||
|
||||
&.logo-sourceforge {
|
||||
mask-image: url('/assets/images/logo-sourceforge.svg');
|
||||
}
|
||||
@ -131,6 +136,7 @@
|
||||
&.logo-privacy-tools,
|
||||
&.logo-reddit,
|
||||
&.logo-sackgeld,
|
||||
&.logo-selfh-st,
|
||||
&.logo-sourceforge,
|
||||
&.logo-umbrel,
|
||||
&.logo-unraid {
|
||||
|
@ -16,7 +16,6 @@ import { PageEvent } from '@angular/material/paginator';
|
||||
import { Sort, SortDirection } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Order as OrderModel } from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
@ -33,7 +32,6 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa
|
||||
templateUrl: './activities-page.html'
|
||||
})
|
||||
export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
public activities: Activity[];
|
||||
public dataSource: MatTableDataSource<Activity>;
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
@ -64,20 +62,24 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['createDialog']) {
|
||||
this.openCreateActivityDialog();
|
||||
if (params['activityId']) {
|
||||
this.dataService
|
||||
.fetchActivity(params['activityId'])
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((activity) => {
|
||||
this.openCreateActivityDialog(activity);
|
||||
});
|
||||
} else {
|
||||
this.openCreateActivityDialog();
|
||||
}
|
||||
} else if (params['editDialog']) {
|
||||
if (this.activities) {
|
||||
const activity = this.activities.find(({ id }) => {
|
||||
return id === params['activityId'];
|
||||
});
|
||||
|
||||
this.openUpdateActivityDialog(activity);
|
||||
} else if (this.dataSource) {
|
||||
const activity = this.dataSource.data.find(({ id }) => {
|
||||
return id === params['activityId'];
|
||||
});
|
||||
|
||||
this.openUpdateActivityDialog(activity);
|
||||
if (params['activityId']) {
|
||||
this.dataService
|
||||
.fetchActivity(params['activityId'])
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((activity) => {
|
||||
this.openUpdateActivityDialog(activity);
|
||||
});
|
||||
} else {
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
}
|
||||
@ -249,7 +251,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
this.fetchActivities();
|
||||
}
|
||||
|
||||
public onUpdateActivity(aActivity: OrderModel) {
|
||||
public onUpdateActivity(aActivity: Activity) {
|
||||
this.router.navigate([], {
|
||||
queryParams: { activityId: aActivity.id, editDialog: true }
|
||||
});
|
||||
|
@ -51,9 +51,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
||||
public isLoading = false;
|
||||
public isToday = isToday;
|
||||
public mode: 'create' | 'update';
|
||||
public platforms: { id: string; name: string }[];
|
||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
public tags: Tag[] = [];
|
||||
public separatorKeysCodes: number[] = [COMMA, ENTER];
|
||||
public tagsAvailable: Tag[] = [];
|
||||
public total = 0;
|
||||
public typesTranslationMap = new Map<Type, string>();
|
||||
public Validators = Validators;
|
||||
@ -71,6 +72,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
) {}
|
||||
|
||||
public ngOnInit() {
|
||||
this.mode = this.data.activity.id ? 'update' : 'create';
|
||||
this.locale = this.data.user?.settings?.locale;
|
||||
this.dateAdapter.setLocale(this.locale);
|
||||
|
||||
@ -79,7 +81,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
this.currencies = currencies;
|
||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||
this.platforms = platforms;
|
||||
this.tags = tags.map(({ id, name }) => {
|
||||
this.tagsAvailable = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
name: translate(name)
|
||||
@ -92,7 +94,9 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
|
||||
this.activityForm = this.formBuilder.group({
|
||||
accountId: [
|
||||
this.data.accounts.length === 1 && !this.data.activity?.accountId
|
||||
this.data.accounts.length === 1 &&
|
||||
!this.data.activity?.accountId &&
|
||||
this.mode === 'create'
|
||||
? this.data.accounts[0].id
|
||||
: this.data.activity?.accountId,
|
||||
Validators.required
|
||||
@ -283,7 +287,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
].valueChanges.pipe(
|
||||
startWith(this.activityForm.get('tags').value),
|
||||
map((aTags: Tag[] | null) => {
|
||||
return aTags ? this.filterTags(aTags) : this.tags.slice();
|
||||
return aTags ? this.filterTags(aTags) : this.tagsAvailable.slice();
|
||||
})
|
||||
);
|
||||
|
||||
@ -437,10 +441,11 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public onAddTag(event: MatAutocompleteSelectedEvent) {
|
||||
this.activityForm.get('tags').setValue([
|
||||
...(this.activityForm.get('tags').value ?? []),
|
||||
this.tags.find(({ id }) => {
|
||||
this.tagsAvailable.find(({ id }) => {
|
||||
return id === event.option.value;
|
||||
})
|
||||
]);
|
||||
|
||||
this.tagInput.nativeElement.value = '';
|
||||
}
|
||||
|
||||
@ -479,18 +484,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
};
|
||||
|
||||
try {
|
||||
if (this.data.activity.id) {
|
||||
(activity as UpdateOrderDto).id = this.data.activity.id;
|
||||
|
||||
await validateObjectForForm({
|
||||
classDto: UpdateOrderDto,
|
||||
form: this.activityForm,
|
||||
ignoreFields: ['dataSource', 'date'],
|
||||
object: activity as UpdateOrderDto
|
||||
});
|
||||
|
||||
this.dialogRef.close(activity as UpdateOrderDto);
|
||||
} else {
|
||||
if (this.mode === 'create') {
|
||||
(activity as CreateOrderDto).updateAccountBalance =
|
||||
this.activityForm.get('updateAccountBalance').value;
|
||||
|
||||
@ -502,6 +496,17 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
});
|
||||
|
||||
this.dialogRef.close(activity as CreateOrderDto);
|
||||
} else {
|
||||
(activity as UpdateOrderDto).id = this.data.activity.id;
|
||||
|
||||
await validateObjectForForm({
|
||||
classDto: UpdateOrderDto,
|
||||
form: this.activityForm,
|
||||
ignoreFields: ['dataSource', 'date'],
|
||||
object: activity as UpdateOrderDto
|
||||
});
|
||||
|
||||
this.dialogRef.close(activity as UpdateOrderDto);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@ -514,12 +519,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
}
|
||||
|
||||
private filterTags(aTags: Tag[]) {
|
||||
const tagIds = aTags.map((tag) => {
|
||||
return tag.id;
|
||||
const tagIds = aTags.map(({ id }) => {
|
||||
return id;
|
||||
});
|
||||
|
||||
return this.tags.filter((tag) => {
|
||||
return !tagIds.includes(tag.id);
|
||||
return this.tagsAvailable.filter(({ id }) => {
|
||||
return !tagIds.includes(id);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -4,10 +4,10 @@
|
||||
(keyup.enter)="activityForm.valid && onSubmit()"
|
||||
(ngSubmit)="onSubmit()"
|
||||
>
|
||||
@if (data.activity.id) {
|
||||
<h1 i18n mat-dialog-title>Update activity</h1>
|
||||
} @else {
|
||||
@if (mode === 'create') {
|
||||
<h1 i18n mat-dialog-title>Add activity</h1>
|
||||
} @else {
|
||||
<h1 i18n mat-dialog-title>Update activity</h1>
|
||||
}
|
||||
<div class="flex-grow-1 py-3" mat-dialog-content>
|
||||
<div class="mb-3">
|
||||
@ -76,16 +76,17 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div [ngClass]="{ 'mb-3': data.activity.id }">
|
||||
<div [ngClass]="{ 'mb-3': mode === 'update' }">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="w-100"
|
||||
[ngClass]="{ 'mb-1 without-hint': !data.activity.id }"
|
||||
[ngClass]="{ 'mb-1 without-hint': mode === 'create' }"
|
||||
>
|
||||
<mat-label i18n>Account</mat-label>
|
||||
<mat-select formControlName="accountId">
|
||||
@if (
|
||||
!activityForm.get('accountId').hasValidator(Validators.required)
|
||||
!activityForm.get('accountId').hasValidator(Validators.required) ||
|
||||
(!activityForm.get('accountId').value && mode === 'update')
|
||||
) {
|
||||
<mat-option [value]="null" />
|
||||
}
|
||||
@ -106,7 +107,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3" [ngClass]="{ 'd-none': data.activity.id }">
|
||||
<div class="mb-3" [ngClass]="{ 'd-none': mode === 'update' }">
|
||||
<mat-checkbox color="primary" formControlName="updateAccountBalance" i18n
|
||||
>Update Cash Balance</mat-checkbox
|
||||
>
|
||||
@ -377,11 +378,11 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3" [ngClass]="{ 'd-none': tags?.length < 1 }">
|
||||
<div class="mb-3" [ngClass]="{ 'd-none': tagsAvailable?.length < 1 }">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Tags</mat-label>
|
||||
<mat-chip-grid #tagsChipList>
|
||||
@for (tag of activityForm.get('tags')?.value; track tag) {
|
||||
@for (tag of activityForm.get('tags')?.value; track tag.id) {
|
||||
<mat-chip-row
|
||||
matChipRemove
|
||||
[removable]="true"
|
||||
@ -403,7 +404,7 @@
|
||||
#autocompleteTags="matAutocomplete"
|
||||
(optionSelected)="onAddTag($event)"
|
||||
>
|
||||
@for (tag of filteredTagsObservable | async; track tag) {
|
||||
@for (tag of filteredTagsObservable | async; track tag.id) {
|
||||
<mat-option [value]="tag.id">
|
||||
{{ tag.name }}
|
||||
</mat-option>
|
||||
|
@ -38,6 +38,7 @@ import { ImportActivitiesDialogParams } from './interfaces/interfaces';
|
||||
export class ImportActivitiesDialog implements OnDestroy {
|
||||
public accounts: CreateAccountDto[] = [];
|
||||
public activities: Activity[] = [];
|
||||
public assetProfileForm: FormGroup;
|
||||
public dataSource: MatTableDataSource<Activity>;
|
||||
public details: any[] = [];
|
||||
public deviceType: string;
|
||||
@ -53,7 +54,6 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
public sortDirection: SortDirection = 'desc';
|
||||
public stepperOrientation: StepperOrientation;
|
||||
public totalItems: number;
|
||||
public uniqueAssetForm: FormGroup;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -73,8 +73,8 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
this.stepperOrientation =
|
||||
this.deviceType === 'mobile' ? 'vertical' : 'horizontal';
|
||||
|
||||
this.uniqueAssetForm = this.formBuilder.group({
|
||||
uniqueAsset: [undefined, Validators.required]
|
||||
this.assetProfileForm = this.formBuilder.group({
|
||||
assetProfileIdentifier: [undefined, Validators.required]
|
||||
});
|
||||
|
||||
if (
|
||||
@ -85,7 +85,7 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
|
||||
this.dialogTitle = $localize`Import Dividends`;
|
||||
this.mode = 'DIVIDEND';
|
||||
this.uniqueAssetForm.get('uniqueAsset').disable();
|
||||
this.assetProfileForm.get('assetProfileIdentifier').disable();
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioHoldings({
|
||||
@ -102,7 +102,7 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
this.holdings = sortBy(holdings, ({ name }) => {
|
||||
return name.toLowerCase();
|
||||
});
|
||||
this.uniqueAssetForm.get('uniqueAsset').enable();
|
||||
this.assetProfileForm.get('assetProfileIdentifier').enable();
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
@ -167,10 +167,11 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
}
|
||||
|
||||
public onLoadDividends(aStepper: MatStepper) {
|
||||
this.uniqueAssetForm.get('uniqueAsset').disable();
|
||||
this.assetProfileForm.get('assetProfileIdentifier').disable();
|
||||
|
||||
const { dataSource, symbol } =
|
||||
this.uniqueAssetForm.get('uniqueAsset').value;
|
||||
const { dataSource, symbol } = this.assetProfileForm.get(
|
||||
'assetProfileIdentifier'
|
||||
).value;
|
||||
|
||||
this.dataService
|
||||
.fetchDividendsImport({
|
||||
@ -193,7 +194,7 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
this.details = [];
|
||||
this.errorMessages = [];
|
||||
this.importStep = ImportStep.SELECT_ACTIVITIES;
|
||||
this.uniqueAssetForm.get('uniqueAsset').enable();
|
||||
this.assetProfileForm.get('assetProfileIdentifier').enable();
|
||||
|
||||
aStepper.reset();
|
||||
}
|
||||
|
@ -25,14 +25,14 @@
|
||||
<div class="pt-3">
|
||||
@if (mode === 'DIVIDEND') {
|
||||
<form
|
||||
[formGroup]="uniqueAssetForm"
|
||||
[formGroup]="assetProfileForm"
|
||||
(ngSubmit)="onLoadDividends(stepper)"
|
||||
>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Holding</mat-label>
|
||||
<mat-select formControlName="uniqueAsset">
|
||||
<mat-select formControlName="assetProfileIdentifier">
|
||||
<mat-select-trigger>{{
|
||||
uniqueAssetForm.get('uniqueAsset')?.value?.name
|
||||
assetProfileForm.get('assetProfileIdentifier')?.value?.name
|
||||
}}</mat-select-trigger>
|
||||
@for (holding of holdings; track holding) {
|
||||
<mat-option
|
||||
@ -63,7 +63,7 @@
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
type="submit"
|
||||
[disabled]="!uniqueAssetForm.valid"
|
||||
[disabled]="!assetProfileForm.valid"
|
||||
>
|
||||
<span i18n>Load Dividends</span>
|
||||
</button>
|
||||
|
@ -6,12 +6,13 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { prettifySymbol } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
Holding,
|
||||
PortfolioDetails,
|
||||
PortfolioPosition,
|
||||
UniqueAsset,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Market, MarketAdvanced } from '@ghostfolio/common/types';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
|
||||
@ -161,7 +162,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
public onAccountChartClicked({ symbol }: UniqueAsset) {
|
||||
public onAccountChartClicked({ symbol }: AssetProfileIdentifier) {
|
||||
if (symbol && symbol !== UNKNOWN_KEY) {
|
||||
this.router.navigate([], {
|
||||
queryParams: { accountId: symbol, accountDetailDialog: true }
|
||||
@ -169,7 +170,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public onSymbolChartClicked({ dataSource, symbol }: UniqueAsset) {
|
||||
public onSymbolChartClicked({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
if (dataSource && symbol) {
|
||||
this.router.navigate([], {
|
||||
queryParams: { dataSource, symbol, holdingDetailDialog: true }
|
||||
@ -584,7 +585,11 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
data: <AccountDetailDialogParams>{
|
||||
accountId: aAccountId,
|
||||
deviceType: this.deviceType,
|
||||
hasImpersonationId: this.hasImpersonationId
|
||||
hasImpersonationId: this.hasImpersonationId,
|
||||
hasPermissionToCreateOrder:
|
||||
!this.hasImpersonationId &&
|
||||
hasPermission(this.user?.permissions, permissions.createOrder) &&
|
||||
!this.user?.settings?.isRestrictedView
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
|
@ -253,7 +253,8 @@
|
||||
} @else {
|
||||
{{ baseCurrency }} <strong>{{ price }}</strong>
|
||||
}
|
||||
<span i18n>per year</span></span
|
||||
<span> </span>
|
||||
<span i18n>per year</span></span
|
||||
>
|
||||
</p>
|
||||
@if (
|
||||
|
@ -152,7 +152,7 @@
|
||||
</div>
|
||||
<div class="row my-5">
|
||||
<div class="col-md-10 offset-md-1">
|
||||
<h2 class="h4 mb-1 text-center">
|
||||
<h2 class="h4 mb-1 text-center" i18n>
|
||||
Would you like to <strong>refine</strong> your
|
||||
<strong>personal investment strategy</strong>?
|
||||
</h2>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { Product } from '@ghostfolio/common/interfaces';
|
||||
import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnInit } from '@angular/core';
|
||||
@ -26,6 +27,7 @@ export class GfProductPageComponent implements OnInit {
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
public tags: string[];
|
||||
|
||||
public constructor(
|
||||
private dataService: DataService,
|
||||
@ -56,7 +58,7 @@ export class GfProductPageComponent implements OnInit {
|
||||
],
|
||||
name: 'Ghostfolio',
|
||||
origin: $localize`Switzerland`,
|
||||
region: $localize`Global`,
|
||||
regions: [$localize`Global`],
|
||||
slogan: 'Open Source Wealth Management',
|
||||
useAnonymously: true
|
||||
};
|
||||
@ -64,5 +66,41 @@ export class GfProductPageComponent implements OnInit {
|
||||
this.product2 = personalFinanceTools.find(({ key }) => {
|
||||
return key === this.route.snapshot.data['key'];
|
||||
});
|
||||
|
||||
if (this.product2.origin) {
|
||||
this.product2.origin = translate(this.product2.origin);
|
||||
}
|
||||
|
||||
if (this.product2.regions) {
|
||||
this.product2.regions = this.product2.regions.map((region) => {
|
||||
return translate(region);
|
||||
});
|
||||
}
|
||||
|
||||
this.tags = [
|
||||
this.product1.name,
|
||||
this.product2.name,
|
||||
$localize`Alternative`,
|
||||
$localize`App`,
|
||||
$localize`Budgeting`,
|
||||
$localize`Community`,
|
||||
$localize`Family Office`,
|
||||
`Fintech`,
|
||||
$localize`Investment`,
|
||||
$localize`Investor`,
|
||||
$localize`Open Source`,
|
||||
`OSS`,
|
||||
$localize`Personal Finance`,
|
||||
$localize`Privacy`,
|
||||
$localize`Portfolio`,
|
||||
$localize`Software`,
|
||||
$localize`Tool`,
|
||||
$localize`User Experience`,
|
||||
$localize`Wealth`,
|
||||
$localize`Wealth Management`,
|
||||
`WealthTech`
|
||||
].sort((a, b) => {
|
||||
return a.localeCompare(b, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -80,8 +80,24 @@
|
||||
</tr>
|
||||
<tr class="mat-mdc-row">
|
||||
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>Region</td>
|
||||
<td class="mat-mdc-cell px-1 py-2">{{ product1.region }}</td>
|
||||
<td class="mat-mdc-cell px-1 py-2">{{ product2.region }}</td>
|
||||
<td class="mat-mdc-cell px-1 py-2">
|
||||
@for (
|
||||
region of product1.regions;
|
||||
track region;
|
||||
let isLast = $last
|
||||
) {
|
||||
{{ region }}{{ isLast ? '' : ', ' }}
|
||||
}
|
||||
</td>
|
||||
<td class="mat-mdc-cell px-1 py-2">
|
||||
@for (
|
||||
region of product2.regions;
|
||||
track region;
|
||||
let isLast = $last
|
||||
) {
|
||||
{{ region }}{{ isLast ? '' : ', ' }}
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="mat-mdc-row">
|
||||
<td class="mat-mdc-cell px-3 py-2 text-right" i18n>
|
||||
@ -236,69 +252,11 @@
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Alternative</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">App</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Budgeting</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">Family Office</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">{{ product1.name }}</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">Investor</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">Privacy</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">Software</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Tool</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">User Experience</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">WealthTech</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">{{ product2.name }}</span>
|
||||
</li>
|
||||
@for (tag of tags; track tag) {
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">{{ tag }}</span>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
<nav aria-label="breadcrumb">
|
||||
|
@ -7,13 +7,13 @@ import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AssetProfileIdentifier,
|
||||
AdminData,
|
||||
AdminJobs,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile,
|
||||
Filter,
|
||||
UniqueAsset
|
||||
Filter
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
@ -35,7 +35,7 @@ export class AdminService {
|
||||
private http: HttpClient
|
||||
) {}
|
||||
|
||||
public addAssetProfile({ dataSource, symbol }: UniqueAsset) {
|
||||
public addAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
return this.http.post<void>(
|
||||
`/api/v1/admin/profile-data/${dataSource}/${symbol}`,
|
||||
null
|
||||
@ -62,7 +62,7 @@ export class AdminService {
|
||||
return this.http.delete<void>(`/api/v1/platform/${aId}`);
|
||||
}
|
||||
|
||||
public deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
public deleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
return this.http.delete<void>(
|
||||
`/api/v1/admin/profile-data/${dataSource}/${symbol}`
|
||||
);
|
||||
@ -167,7 +167,10 @@ export class AdminService {
|
||||
return this.http.post<void>('/api/v1/admin/gather/profile-data', {});
|
||||
}
|
||||
|
||||
public gatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
public gatherProfileDataBySymbol({
|
||||
dataSource,
|
||||
symbol
|
||||
}: AssetProfileIdentifier) {
|
||||
return this.http.post<void>(
|
||||
`/api/v1/admin/gather/profile-data/${dataSource}/${symbol}`,
|
||||
{}
|
||||
@ -178,7 +181,7 @@ export class AdminService {
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
}: UniqueAsset & {
|
||||
}: AssetProfileIdentifier & {
|
||||
date?: Date;
|
||||
}) {
|
||||
let url = `/api/v1/admin/gather/${dataSource}/${symbol}`;
|
||||
@ -217,7 +220,7 @@ export class AdminService {
|
||||
symbol,
|
||||
symbolMapping,
|
||||
url
|
||||
}: UniqueAsset & UpdateAssetProfileDto) {
|
||||
}: AssetProfileIdentifier & UpdateAssetProfileDto) {
|
||||
return this.http.patch<EnhancedSymbolProfile>(
|
||||
`/api/v1/admin/profile-data/${dataSource}/${symbol}`,
|
||||
{
|
||||
@ -272,7 +275,7 @@ export class AdminService {
|
||||
dataSource,
|
||||
scraperConfiguration,
|
||||
symbol
|
||||
}: UniqueAsset & UpdateAssetProfileDto['scraperConfiguration']) {
|
||||
}: AssetProfileIdentifier & UpdateAssetProfileDto['scraperConfiguration']) {
|
||||
return this.http.post<any>(
|
||||
`/api/v1/admin/market-data/${dataSource}/${symbol}/test`,
|
||||
{
|
||||
|
@ -4,7 +4,10 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
|
||||
import { TransferBalanceDto } from '@ghostfolio/api/app/account/transfer-balance.dto';
|
||||
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
Activities,
|
||||
Activity
|
||||
} from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||
import { PortfolioHoldingDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-holding-detail.interface';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
@ -20,6 +23,7 @@ import {
|
||||
AccountBalancesResponse,
|
||||
Accounts,
|
||||
AdminMarketDataDetails,
|
||||
AssetProfileIdentifier,
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse,
|
||||
Export,
|
||||
@ -34,7 +38,6 @@ import {
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
UniqueAsset,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { filterGlobalPermissions } from '@ghostfolio/common/permissions';
|
||||
@ -47,7 +50,8 @@ import { SortDirection } from '@angular/material/sort';
|
||||
import {
|
||||
AccountBalance,
|
||||
DataSource,
|
||||
Order as OrderModel
|
||||
Order as OrderModel,
|
||||
Tag
|
||||
} from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { cloneDeep, groupBy, isNumber } from 'lodash';
|
||||
@ -211,6 +215,17 @@ export class DataService {
|
||||
);
|
||||
}
|
||||
|
||||
public fetchActivity(aActivityId: string) {
|
||||
return this.http.get<Activity>(`/api/v1/order/${aActivityId}`).pipe(
|
||||
map((activity) => {
|
||||
activity.createdAt = parseISO(<string>(<unknown>activity.createdAt));
|
||||
activity.date = parseISO(<string>(<unknown>activity.date));
|
||||
|
||||
return activity;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public fetchDividends({
|
||||
filters,
|
||||
groupBy = 'month',
|
||||
@ -229,7 +244,7 @@ export class DataService {
|
||||
});
|
||||
}
|
||||
|
||||
public fetchDividendsImport({ dataSource, symbol }: UniqueAsset) {
|
||||
public fetchDividendsImport({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
return this.http.get<ImportResponse>(
|
||||
`/api/v1/import/dividends/${dataSource}/${symbol}`
|
||||
);
|
||||
@ -269,7 +284,7 @@ export class DataService {
|
||||
return this.http.delete<any>(`/api/v1/order/${aId}`);
|
||||
}
|
||||
|
||||
public deleteBenchmark({ dataSource, symbol }: UniqueAsset) {
|
||||
public deleteBenchmark({ dataSource, symbol }: AssetProfileIdentifier) {
|
||||
return this.http.delete<any>(`/api/v1/benchmark/${dataSource}/${symbol}`);
|
||||
}
|
||||
|
||||
@ -288,7 +303,7 @@ export class DataService {
|
||||
public fetchAsset({
|
||||
dataSource,
|
||||
symbol
|
||||
}: UniqueAsset): Observable<AdminMarketDataDetails> {
|
||||
}: AssetProfileIdentifier): Observable<AdminMarketDataDetails> {
|
||||
return this.http.get<any>(`/api/v1/asset/${dataSource}/${symbol}`).pipe(
|
||||
map((data) => {
|
||||
for (const item of data.marketData) {
|
||||
@ -307,7 +322,7 @@ export class DataService {
|
||||
}: {
|
||||
range: DateRange;
|
||||
startDate: Date;
|
||||
} & UniqueAsset): Observable<BenchmarkMarketDataDetails> {
|
||||
} & AssetProfileIdentifier): Observable<BenchmarkMarketDataDetails> {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (range) {
|
||||
@ -629,7 +644,7 @@ export class DataService {
|
||||
);
|
||||
}
|
||||
|
||||
public postBenchmark(benchmark: UniqueAsset) {
|
||||
public postBenchmark(benchmark: AssetProfileIdentifier) {
|
||||
return this.http.post(`/api/v1/benchmark`, benchmark);
|
||||
}
|
||||
|
||||
@ -649,6 +664,17 @@ export class DataService {
|
||||
return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData);
|
||||
}
|
||||
|
||||
public putHoldingTags({
|
||||
dataSource,
|
||||
symbol,
|
||||
tags
|
||||
}: { tags: Tag[] } & AssetProfileIdentifier) {
|
||||
return this.http.put<void>(
|
||||
`/api/v1/portfolio/position/${dataSource}/${symbol}/tags`,
|
||||
{ tags }
|
||||
);
|
||||
}
|
||||
|
||||
public putOrder(aOrder: UpdateOrderDto) {
|
||||
return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
|
||||
}
|
||||
|
1
apps/client/src/assets/images/logo-selfh-st.svg
Normal file
1
apps/client/src/assets/images/logo-selfh-st.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 323 87"><defs><style>.cls-1{opacity:0.1;}</style></defs><g class="cls-1"><path d="M201.13,52.72a30.78,30.78,0,0,1,3-13.73,21.72,21.72,0,0,1,8.55-9.33,24.91,24.91,0,0,1,12.94-3.31q10.48,0,17.11,6.42t7.39,17.41l.1,3.55q0,11.91-6.66,19.11T225.68,80q-11.2,0-17.88-7.17t-6.67-19.53Zm13.83,1q0,7.38,2.77,11.29a10,10,0,0,0,15.79.05q2.83-3.87,2.83-12.34,0-7.25-2.83-11.22a9.18,9.18,0,0,0-7.94-4,9,9,0,0,0-7.85,4C215.88,44.09,215,48.18,215,53.7Z"/></g><path d="M35.37,64.78a4.47,4.47,0,0,0-2.52-4,28.42,28.42,0,0,0-8.06-2.6Q6.33,54.3,6.32,42.48A14.24,14.24,0,0,1,12,31q5.72-4.62,15-4.62,9.86,0,15.77,4.65a14.61,14.61,0,0,1,5.91,12H34.84a6.71,6.71,0,0,0-1.91-4.9q-1.92-1.93-6-1.94a8.26,8.26,0,0,0-5.4,1.58,5,5,0,0,0-1.92,4,4.27,4.27,0,0,0,2.18,3.71A22.51,22.51,0,0,0,29.15,48a60.73,60.73,0,0,1,8.7,2.32q11,4,11,13.93a13.49,13.49,0,0,1-6.08,11.46Q36.66,80,27,80a27.37,27.37,0,0,1-11.56-2.32,19.36,19.36,0,0,1-7.92-6.36,14.83,14.83,0,0,1-2.87-8.73H17.8a7.23,7.23,0,0,0,2.73,5.64,10.82,10.82,0,0,0,6.8,2,10,10,0,0,0,6-1.5A4.69,4.69,0,0,0,35.37,64.78Z"/><path d="M79.58,80q-11.39,0-18.54-7T53.89,54.44V53.1a31.28,31.28,0,0,1,3-14,22.21,22.21,0,0,1,8.54-9.47,24,24,0,0,1,12.61-3.33q10.62,0,16.73,6.7t6.1,19V57.7h-33a12.79,12.79,0,0,0,4,8.13,12.24,12.24,0,0,0,8.54,3.06q8,0,12.49-5.79l6.8,7.61a20.71,20.71,0,0,1-8.43,6.87A27.65,27.65,0,0,1,79.58,80ZM78,37.5a8.62,8.62,0,0,0-6.67,2.79,14.43,14.43,0,0,0-3.28,8H87.29V47.16A10.34,10.34,0,0,0,84.8,40,8.94,8.94,0,0,0,78,37.5Z"/><path d="M121.36,79.09H107.48V5.59h13.88Z"/><path d="M134.57,79.09V37.46h-7.71V27.31h7.71v-4.4q0-8.71,5-13.52t14-4.81a32.27,32.27,0,0,1,7,1l-.14,10.72a17.57,17.57,0,0,0-4.22-.43q-7.8,0-7.8,7.32v4.16h10.29V37.46H148.44V79.09Z"/><path d="M177.44,33a17.28,17.28,0,0,1,13.83-6.61q16.85,0,17.09,19.58V79.09H194.53V46.31q0-4.45-1.92-6.58t-6.36-2.13q-6.08,0-8.81,4.69v36.8H163.62V5.59h13.82Z"/><path d="M271.22,64.78a4.48,4.48,0,0,0-2.51-4,28.42,28.42,0,0,0-8.06-2.6q-18.48-3.88-18.47-15.7A14.22,14.22,0,0,1,247.9,31q5.72-4.62,15-4.62,9.86,0,15.77,4.65a14.64,14.64,0,0,1,5.91,12H270.7a6.68,6.68,0,0,0-1.92-4.9c-1.27-1.29-3.27-1.94-6-1.94a8.31,8.31,0,0,0-5.41,1.58,5,5,0,0,0-1.91,4,4.27,4.27,0,0,0,2.18,3.71A22.52,22.52,0,0,0,265,48a60.54,60.54,0,0,1,8.71,2.32q11,4,11,13.93a13.51,13.51,0,0,1-6.08,11.46Q272.52,80,262.9,80a27.37,27.37,0,0,1-11.56-2.32,19.24,19.24,0,0,1-7.92-6.36,14.83,14.83,0,0,1-2.87-8.73h13.11a7.23,7.23,0,0,0,2.73,5.64,10.79,10.79,0,0,0,6.79,2,10.07,10.07,0,0,0,6-1.5A4.71,4.71,0,0,0,271.22,64.78Z"/><path d="M308.17,14.58V27.31H317V37.46h-8.85V63.3a6.13,6.13,0,0,0,1.1,4.11c.73.83,2.13,1.25,4.21,1.25a22.24,22.24,0,0,0,4.06-.34V78.8A28.42,28.42,0,0,1,309.17,80q-14.55,0-14.83-14.69V37.46h-7.56V27.31h7.56V14.58Z"/><path d="M222.41,75.64a4.12,4.12,0,0,1,1.07-2.85,3.87,3.87,0,0,1,3-1.17,4,4,0,0,1,3,1.17,4.06,4.06,0,0,1,1.1,2.85,3.68,3.68,0,0,1-1.1,2.75,4.14,4.14,0,0,1-3,1.08,4,4,0,0,1-3-1.08A3.73,3.73,0,0,1,222.41,75.64Z"/></svg>
|
After Width: | Height: | Size: 2.9 KiB |
@ -7,10 +7,16 @@
|
||||
{
|
||||
"sizes": "192x192",
|
||||
"src": "/assets/android-chrome-192x192.png",
|
||||
"type": "image/png",
|
||||
"purpose": "any maskable"
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"purpose": "any",
|
||||
"sizes": "512x512",
|
||||
"src": "/assets/android-chrome-512x512.png",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"purpose": "maskable",
|
||||
"sizes": "512x512",
|
||||
"src": "/assets/android-chrome-512x512.png",
|
||||
"type": "image/png"
|
||||
@ -21,5 +27,5 @@
|
||||
"short_name": "Ghostfolio",
|
||||
"start_url": "/en/",
|
||||
"theme_color": "#FFFFFF",
|
||||
"url": "https://www.ghostfol.io"
|
||||
"url": "https://ghostfol.io"
|
||||
}
|
||||
|
6958
apps/client/src/locales/messages.ca.xlf
Normal file
6958
apps/client/src/locales/messages.ca.xlf
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user