Compare commits
82 Commits
Author | SHA1 | Date | |
---|---|---|---|
a83441b3ba | |||
075431d868 | |||
0168c1c4e8 | |||
07de8f87fc | |||
3e16041c16 | |||
5882b7914d | |||
69c9e259b1 | |||
aca37a27f9 | |||
313d2a2f79 | |||
9ac67b0af2 | |||
1e526852a7 | |||
e54638a684 | |||
0179823ad9 | |||
029b7bed9a | |||
635f10e2d0 | |||
cebf879d67 | |||
124bdc028d | |||
d69a69ce18 | |||
15344513ce | |||
b291d9e031 | |||
bee702302f | |||
bb56e09a13 | |||
0873f539c5 | |||
6dcd801d05 | |||
77065dac50 | |||
438484879d | |||
e37a650c70 | |||
6e8c90b3fc | |||
9e1a7fc981 | |||
ff638adf03 | |||
fa44cee781 | |||
db1d474ddf | |||
994275e093 | |||
ee397c8047 | |||
7203939c42 | |||
9725f16c81 | |||
bb8b1e4f43 | |||
9d3610331a | |||
0043b44670 | |||
bbc4e64cb4 | |||
c7f4825499 | |||
8f583709ef | |||
4c30212a72 | |||
cade2f6a5e | |||
3b9a8fabb5 | |||
3435b3a348 | |||
5d39b267ab | |||
ffaaa14dba | |||
c65746d119 | |||
1a6840f1f6 | |||
fb7fb886f6 | |||
127abb8f4e | |||
ed1136999a | |||
9f545e3e2b | |||
1602f976f0 | |||
4bf4c1a8a3 | |||
e78755c280 | |||
7772684413 | |||
955302666e | |||
ddce8cc7f9 | |||
aca0d77e91 | |||
8b9379f5ce | |||
0806d0dc92 | |||
e518bc3779 | |||
eff807dd9a | |||
155bf67f60 | |||
9aefe3747e | |||
0878febded | |||
f953c6ea64 | |||
76dbf78279 | |||
f12866b9ec | |||
83ba5f3d9f | |||
7439c1bf54 | |||
e255b76053 | |||
ed7209fb53 | |||
008a2ab123 | |||
2aedd74480 | |||
11076592d1 | |||
ebee851b23 | |||
7d3f1832b4 | |||
39e6abfc8c | |||
78e0fdb0ca |
31
.travis.yml
31
.travis.yml
@ -3,9 +3,28 @@ git:
|
|||||||
depth: false
|
depth: false
|
||||||
node_js:
|
node_js:
|
||||||
- 14
|
- 14
|
||||||
before_script:
|
|
||||||
- yarn
|
services:
|
||||||
script:
|
- docker
|
||||||
- yarn format:check
|
|
||||||
- yarn test
|
cache: yarn
|
||||||
- yarn build:all
|
|
||||||
|
if: (type = pull_request) OR (tag IS present)
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
include:
|
||||||
|
- stage: Install dependencies
|
||||||
|
if: type = pull_request
|
||||||
|
script: yarn --frozen-lockfile
|
||||||
|
- stage: Check formatting
|
||||||
|
if: type = pull_request
|
||||||
|
script: yarn format:check
|
||||||
|
- stage: Execute tests
|
||||||
|
if: type = pull_request
|
||||||
|
script: yarn test
|
||||||
|
- stage: Build application
|
||||||
|
if: type = pull_request
|
||||||
|
script: yarn build:all
|
||||||
|
- stage: Build and publish docker image
|
||||||
|
if: tag IS present
|
||||||
|
script: ./publish-docker-image.sh
|
||||||
|
188
CHANGELOG.md
188
CHANGELOG.md
@ -5,6 +5,194 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.101.0 - 08.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added `GOOGLE_SHEETS` as a new data source type
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Excluded the url pattern of shared portfolios in the `robots.txt` file
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.100.0 - 05.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the _Top 3_ and _Bottom 3_ performers to the analysis page
|
||||||
|
- Added a blog post
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the routing of the create activity dialog
|
||||||
|
- Fixed the link color in the blog posts
|
||||||
|
|
||||||
|
## 1.99.0 - 01.01.2022
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Exposed the profile data gathering by symbol as an endpoint
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the portfolio analysis page: show the y-axis and extend the chart in relation to the days in market
|
||||||
|
- Restructured the about page
|
||||||
|
- Start refactoring _transactions_ to _activities_
|
||||||
|
- Refactored the demo user id
|
||||||
|
- Upgraded `angular` from version `13.0.2` to `13.1.1`
|
||||||
|
- Upgraded `chart.js` from version `3.5.0` to `3.7.0`
|
||||||
|
- Upgraded `Nx` from version `13.3.0` to `13.4.1`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Hid the data provider warning while loading
|
||||||
|
- Fixed an exception with the market state caused by a failed data provider request
|
||||||
|
- Fixed an exception in the portfolio position endpoint
|
||||||
|
- Fixed the reload of the position detail dialog (with query parameters)
|
||||||
|
- Fixed the missing mapping for Russia in the data enhancer for symbol profile data via _Trackinsight_
|
||||||
|
|
||||||
|
## 1.98.0 - 29.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the date range component to the holdings tab
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Extended the statistics section on the about page (users in Slack community)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the creation of historical data in the admin control panel (upsert instead of update)
|
||||||
|
- Fixed the scrolling issue in the position detail dialog on mobile
|
||||||
|
|
||||||
|
## 1.97.0 - 28.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the transactions to the position detail dialog
|
||||||
|
- Added support for dividend
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.96.0 - 27.12.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Made the data provider warning more discreet
|
||||||
|
- Upgraded `http-status-codes` from version `2.1.4` to `2.2.0`
|
||||||
|
- Upgraded `ngx-device-detector` from version `2.1.1` to `3.0.0`
|
||||||
|
- Upgraded `ngx-markdown` from version `12.0.1` to `13.0.0`
|
||||||
|
- Upgraded `ngx-stripe` from version `12.0.2` to `13.0.0`
|
||||||
|
- Upgraded `prisma` from version `3.6.0` to `3.7.0`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the file type detection in the import functionality for transactions
|
||||||
|
|
||||||
|
## 1.95.0 - 26.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a warning to the log if the data gathering fails
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Filtered potential `null` currencies
|
||||||
|
- Improved the 7d data gathering optimization for currencies
|
||||||
|
|
||||||
|
## 1.94.0 - 25.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for cryptocurrencies _Cosmos_ (`ATOM-USD`) and _Polkadot_ (`DOT-USD`)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 30 days
|
||||||
|
- Made the import functionality for transactions by `csv` files more flexible
|
||||||
|
- Optimized the 7d data gathering (only consider symbols with incomplete market data)
|
||||||
|
- Upgraded `prettier` from version `2.3.2` to `2.5.1`
|
||||||
|
|
||||||
|
## 1.93.0 - 21.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added support for cryptocurrency _Solana_ (`SOL-USD`)
|
||||||
|
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Converted errors to warnings in portfolio calculator
|
||||||
|
|
||||||
|
## 1.92.0 - 19.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a line chart to the historical data view in the admin control panel
|
||||||
|
- Supported the update of historical data in the admin control panel
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Improved the redirection on logout
|
||||||
|
- Fixed the permission for the system status page
|
||||||
|
|
||||||
|
## 1.91.0 - 18.12.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the redundant all time high and all time low from the performance endpoint
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the symbol conversion from _Yahoo Finance_ including a hyphen
|
||||||
|
- Fixed hidden values (`0`) in the statistics section on the about page
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.90.0 - 14.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the validation in the import functionality for transactions by checking the currency of the data provider service
|
||||||
|
- Added support for cryptocurrency _Uniswap_
|
||||||
|
- Set up pipeline for docker build
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the default transactions import limit
|
||||||
|
- Improved the landing page in dark mode
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed `/bin/sh: prisma: not found` in docker build
|
||||||
|
- Added `apk` in `Dockerfile` (`python3 g++ make openssl`)
|
||||||
|
|
||||||
|
## 1.89.0 - 11.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Extended the data gathering by symbol endpoint with an optional date
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `Nx` from version `13.2.2` to `13.3.0`
|
||||||
|
- Upgraded `storybook` from version `6.4.0-rc.3` to `6.4.9`
|
||||||
|
|
||||||
|
## 1.88.0 - 09.12.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a coupon system
|
||||||
|
|
||||||
## 1.87.0 - 07.12.2021
|
## 1.87.0 - 07.12.2021
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -12,7 +12,8 @@ COPY ./package.json package.json
|
|||||||
COPY ./yarn.lock yarn.lock
|
COPY ./yarn.lock yarn.lock
|
||||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||||
|
|
||||||
RUN yarn
|
RUN apk add --no-cache python3 g++ make openssl
|
||||||
|
RUN yarn install
|
||||||
|
|
||||||
# See https://github.com/nrwl/nx/issues/6586 for further details
|
# See https://github.com/nrwl/nx/issues/6586 for further details
|
||||||
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
COPY ./decorate-angular-cli.js decorate-angular-cli.js
|
||||||
|
42
README.md
42
README.md
@ -34,7 +34,7 @@
|
|||||||
|
|
||||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||||
|
|
||||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker).
|
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
@ -81,27 +81,43 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
|||||||
|
|
||||||
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.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||||
|
|
||||||
## Run with Docker
|
## Run with Docker (self-hosting)
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||||
|
|
||||||
### Setup Docker Image
|
### a. Run environment
|
||||||
|
|
||||||
Run the following commands to build and start the Docker image:
|
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose-build-local.yml build
|
docker-compose -f docker/docker-compose.yml up
|
||||||
docker-compose -f docker/docker-compose-build-local.yml up
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setup Database
|
#### Setup Database
|
||||||
|
|
||||||
Run the following command to setup the database once Ghostfolio is running:
|
Run the following command to setup the database once Ghostfolio is running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
|
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
||||||
|
```
|
||||||
|
|
||||||
|
### b. Build and run environment
|
||||||
|
|
||||||
|
Run the following commands to build and start the Docker images:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose.build.yml build
|
||||||
|
docker-compose -f docker/docker-compose.build.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Setup Database
|
||||||
|
|
||||||
|
Run the following command to setup the database once Ghostfolio is running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fetch Historical Data
|
### Fetch Historical Data
|
||||||
@ -112,6 +128,12 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
|
### Finalization
|
||||||
|
|
||||||
|
1. Create a new user via _Get Started_
|
||||||
|
1. Assign the role `ADMIN` to this user (directly in the database)
|
||||||
|
1. Delete the original _Admin_ (directly in the database)
|
||||||
|
|
||||||
### Migrate Database
|
### Migrate Database
|
||||||
|
|
||||||
With the following command you can keep your database schema in sync after a Ghostfolio version update:
|
With the following command you can keep your database schema in sync after a Ghostfolio version update:
|
||||||
@ -131,9 +153,7 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
1. Run `cd docker`
|
1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
|
||||||
1. Run `cd -` to go back to the project root directory
|
|
||||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||||
1. Start server and client (see [_Development_](#Development))
|
1. Start server and client (see [_Development_](#Development))
|
||||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
|
||||||
import {
|
import {
|
||||||
AdminData,
|
AdminData,
|
||||||
AdminMarketData,
|
AdminMarketData,
|
||||||
@ -21,17 +21,19 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
import { isDate } from 'date-fns';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
|
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly adminService: AdminService,
|
private readonly adminService: AdminService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly propertyService: PropertyService,
|
private readonly marketDataService: MarketDataService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -74,6 +76,49 @@ export class AdminController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gather/profile-data')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherProfileData(): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherProfileData();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('gather/profile-data/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherProfileDataForSymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
@Post('gather/:dataSource/:symbol')
|
@Post('gather/:dataSource/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherSymbol(
|
public async gatherSymbol(
|
||||||
@ -97,9 +142,13 @@ export class AdminController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data')
|
@Post('gather/:dataSource/:symbol/:dateString')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherProfileData(): Promise<void> {
|
public async gatherSymbolForDate(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('dateString') dateString: string,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<MarketData> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
this.request.user.permissions,
|
this.request.user.permissions,
|
||||||
@ -112,9 +161,20 @@ export class AdminController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.dataGatheringService.gatherProfileData();
|
const date = new Date(dateString);
|
||||||
|
|
||||||
return;
|
if (!isDate(date)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.dataGatheringService.gatherSymbolForDate({
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('market-data')
|
@Get('market-data')
|
||||||
@ -138,7 +198,7 @@ export class AdminController {
|
|||||||
@Get('market-data/:symbol')
|
@Get('market-data/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getMarketDataBySymbol(
|
public async getMarketDataBySymbol(
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol: string
|
||||||
): Promise<AdminMarketDataDetails> {
|
): Promise<AdminMarketDataDetails> {
|
||||||
if (
|
if (
|
||||||
!hasPermission(
|
!hasPermission(
|
||||||
@ -155,6 +215,39 @@ export class AdminController {
|
|||||||
return this.adminService.getMarketDataBySymbol(symbol);
|
return this.adminService.getMarketDataBySymbol(symbol);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async update(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('dateString') dateString: string,
|
||||||
|
@Param('symbol') symbol: string,
|
||||||
|
@Body() data: UpdateMarketDataDto
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
this.request.user.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
return this.marketDataService.updateMarketData({
|
||||||
|
data: { ...data, dataSource },
|
||||||
|
where: {
|
||||||
|
date_symbol: {
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Put('settings/:key')
|
@Put('settings/:key')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async updateProperty(
|
public async updateProperty(
|
||||||
|
6
apps/api/src/app/admin/update-market-data.dto.ts
Normal file
6
apps/api/src/app/admin/update-market-data.dto.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { IsNumber } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateMarketDataDto {
|
||||||
|
@IsNumber()
|
||||||
|
marketPrice: number;
|
||||||
|
}
|
@ -1,4 +1,5 @@
|
|||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Order } from '@prisma/client';
|
import { Order } from '@prisma/client';
|
||||||
@ -6,9 +7,8 @@ import { isSameDay, parseISO } from 'date-fns';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ImportService {
|
export class ImportService {
|
||||||
private static MAX_ORDERS_TO_IMPORT = 20;
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly orderService: OrderService
|
private readonly orderService: OrderService
|
||||||
) {}
|
) {}
|
||||||
@ -59,8 +59,14 @@ export class ImportService {
|
|||||||
orders: Partial<Order>[];
|
orders: Partial<Order>[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
if (orders?.length > ImportService.MAX_ORDERS_TO_IMPORT) {
|
if (
|
||||||
throw new Error('Too many transactions');
|
orders?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Too many transactions (${this.configurationService.get(
|
||||||
|
'MAX_ORDERS_TO_IMPORT'
|
||||||
|
)} at most)`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingOrders = await this.orderService.orders({
|
const existingOrders = await this.orderService.orders({
|
||||||
@ -98,6 +104,12 @@ export class ImportService {
|
|||||||
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (result[symbol].currency !== currency) {
|
||||||
|
throw new Error(
|
||||||
|
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
import {
|
import {
|
||||||
|
DEMO_USER_ID,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
|
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||||
PROPERTY_STRIPE_CONFIG,
|
PROPERTY_STRIPE_CONFIG,
|
||||||
PROPERTY_SYSTEM_MESSAGE
|
PROPERTY_SYSTEM_MESSAGE
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
@ -22,7 +24,6 @@ import { subDays } from 'date-fns';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
@ -187,9 +188,15 @@ export class InfoService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async countSlackCommunityUsers() {
|
||||||
|
return (await this.propertyService.getByKey(
|
||||||
|
PROPERTY_SLACK_COMMUNITY_USERS
|
||||||
|
)) as string;
|
||||||
|
}
|
||||||
|
|
||||||
private getDemoAuthToken() {
|
private getDemoAuthToken() {
|
||||||
return this.jwtService.sign({
|
return this.jwtService.sign({
|
||||||
id: InfoService.DEMO_USER_ID
|
id: DEMO_USER_ID
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,19 +225,19 @@ export class InfoService {
|
|||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const activeUsers1d = await this.countActiveUsers(1);
|
const activeUsers1d = await this.countActiveUsers(1);
|
||||||
const activeUsers7d = await this.countActiveUsers(7);
|
|
||||||
const activeUsers30d = await this.countActiveUsers(30);
|
const activeUsers30d = await this.countActiveUsers(30);
|
||||||
const newUsers30d = await this.countNewUsers(30);
|
const newUsers30d = await this.countNewUsers(30);
|
||||||
const gitHubContributors = await this.countGitHubContributors();
|
const gitHubContributors = await this.countGitHubContributors();
|
||||||
const gitHubStargazers = await this.countGitHubStargazers();
|
const gitHubStargazers = await this.countGitHubStargazers();
|
||||||
|
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||||
|
|
||||||
statistics = {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
activeUsers7d,
|
|
||||||
activeUsers30d,
|
activeUsers30d,
|
||||||
gitHubContributors,
|
gitHubContributors,
|
||||||
gitHubStargazers,
|
gitHubStargazers,
|
||||||
newUsers30d
|
newUsers30d,
|
||||||
|
slackCommunityUsers
|
||||||
};
|
};
|
||||||
|
|
||||||
await this.redisCacheService.set(
|
await this.redisCacheService.set(
|
||||||
|
@ -66,28 +66,21 @@ export class OrderController {
|
|||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let orders = await this.orderService.orders({
|
let orders = await this.orderService.getOrders({
|
||||||
include: {
|
includeDrafts: true,
|
||||||
Account: {
|
userId: impersonationUserId || this.request.user.id
|
||||||
include: {
|
|
||||||
Platform: true
|
|
||||||
}
|
|
||||||
},
|
|
||||||
SymbolProfile: {
|
|
||||||
select: {
|
|
||||||
name: true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
orderBy: { date: 'desc' },
|
|
||||||
where: { userId: impersonationUserId || this.request.user.id }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId ||
|
impersonationUserId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
orders = nullifyValuesInObjects(orders, [
|
||||||
|
'fee',
|
||||||
|
'quantity',
|
||||||
|
'unitPrice',
|
||||||
|
'value'
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return orders;
|
return orders;
|
||||||
|
@ -3,7 +3,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
|
||||||
|
import Big from 'big.js';
|
||||||
import { endOfToday, isAfter } from 'date-fns';
|
import { endOfToday, isAfter } from 'date-fns';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -82,11 +83,13 @@ export class OrderService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getOrders({
|
public async getOrders({
|
||||||
includeDrafts = false,
|
includeDrafts = false,
|
||||||
|
types,
|
||||||
userId
|
userId
|
||||||
}: {
|
}: {
|
||||||
includeDrafts?: boolean;
|
includeDrafts?: boolean;
|
||||||
|
types?: TypeOfOrder[];
|
||||||
userId: string;
|
userId: string;
|
||||||
}) {
|
}) {
|
||||||
const where: Prisma.OrderWhereInput = { userId };
|
const where: Prisma.OrderWhereInput = { userId };
|
||||||
@ -95,15 +98,39 @@ export class OrderService {
|
|||||||
where.isDraft = false;
|
where.isDraft = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.orders({
|
if (types) {
|
||||||
where,
|
where.OR = types.map((type) => {
|
||||||
include: {
|
return {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
type: {
|
||||||
Account: true,
|
equals: type
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
}
|
||||||
SymbolProfile: true
|
};
|
||||||
},
|
});
|
||||||
orderBy: { date: 'asc' }
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
await this.orders({
|
||||||
|
where,
|
||||||
|
include: {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
Account: {
|
||||||
|
include: {
|
||||||
|
Platform: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
|
SymbolProfile: true
|
||||||
|
},
|
||||||
|
orderBy: { date: 'asc' }
|
||||||
|
})
|
||||||
|
).map((order) => {
|
||||||
|
return {
|
||||||
|
...order,
|
||||||
|
value: new Big(order.quantity)
|
||||||
|
.mul(order.unitPrice)
|
||||||
|
.plus(order.fee)
|
||||||
|
.toNumber()
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
|
||||||
export interface PortfolioOrder {
|
export interface PortfolioOrder {
|
||||||
@ -10,6 +9,6 @@ export interface PortfolioOrder {
|
|||||||
name: string;
|
name: string;
|
||||||
quantity: Big;
|
quantity: Big;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
type: OrderType;
|
type: TypeOfOrder;
|
||||||
unitPrice: Big;
|
unitPrice: Big;
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
@ -16,6 +17,7 @@ export interface PortfolioPositionDetail {
|
|||||||
name: string;
|
name: string;
|
||||||
netPerformance: number;
|
netPerformance: number;
|
||||||
netPerformancePercent: number;
|
netPerformancePercent: number;
|
||||||
|
orders: OrderWithAccount[];
|
||||||
quantity: number;
|
quantity: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,9 +1,9 @@
|
|||||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
import { Logger } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { Type as TypeOfOrder } from '@prisma/client';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
@ -238,9 +238,7 @@ export class PortfolioCalculator {
|
|||||||
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||||
invalidSymbols.push(item.symbol);
|
invalidSymbols.push(item.symbol);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
Logger.error(
|
Logger.warn(`Missing value for symbol ${item.symbol} at ${nextDate}`);
|
||||||
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let lastInvestment: Big = new Big(0);
|
let lastInvestment: Big = new Big(0);
|
||||||
@ -271,7 +269,7 @@ export class PortfolioCalculator {
|
|||||||
if (!initialValue) {
|
if (!initialValue) {
|
||||||
invalidSymbols.push(item.symbol);
|
invalidSymbols.push(item.symbol);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
Logger.error(
|
Logger.warn(
|
||||||
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@ -515,7 +513,7 @@ export class PortfolioCalculator {
|
|||||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||||
);
|
);
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
Logger.error(
|
Logger.warn(
|
||||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
|
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
|
||||||
);
|
);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
@ -660,14 +658,14 @@ export class PortfolioCalculator {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFactor(type: OrderType) {
|
private getFactor(type: TypeOfOrder) {
|
||||||
let factor: number;
|
let factor: number;
|
||||||
|
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case OrderType.Buy:
|
case 'BUY':
|
||||||
factor = 1;
|
factor = 1;
|
||||||
break;
|
break;
|
||||||
case OrderType.Sell:
|
case 'SELL':
|
||||||
factor = -1;
|
factor = -1;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
@ -10,12 +10,12 @@ import { baseCurrency } from '@ghostfolio/common/config';
|
|||||||
import {
|
import {
|
||||||
PortfolioChart,
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
|
PortfolioInvestments,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
PortfolioSummary
|
PortfolioSummary
|
||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@ -48,46 +48,10 @@ export class PortfolioController {
|
|||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('investments')
|
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
public async findAll(
|
|
||||||
@Headers('impersonation-id') impersonationId,
|
|
||||||
@Res() res: Response
|
|
||||||
): Promise<InvestmentItem[]> {
|
|
||||||
if (
|
|
||||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
|
||||||
this.request.user.subscription.type === 'Basic'
|
|
||||||
) {
|
|
||||||
res.status(StatusCodes.FORBIDDEN);
|
|
||||||
return <any>res.json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
let investments = await this.portfolioService.getInvestments(
|
|
||||||
impersonationId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationId ||
|
|
||||||
this.userService.isRestrictedView(this.request.user)
|
|
||||||
) {
|
|
||||||
const maxInvestment = investments.reduce(
|
|
||||||
(investment, item) => Math.max(investment, item.investment),
|
|
||||||
1
|
|
||||||
);
|
|
||||||
|
|
||||||
investments = investments.map((item) => ({
|
|
||||||
date: item.date,
|
|
||||||
investment: item.investment / maxInvestment
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
return <any>res.json(investments);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('chart')
|
@Get('chart')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getChart(
|
public async getChart(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<PortfolioChart> {
|
): Promise<PortfolioChart> {
|
||||||
@ -98,18 +62,14 @@ export class PortfolioController {
|
|||||||
|
|
||||||
let chartData = historicalDataContainer.items;
|
let chartData = historicalDataContainer.items;
|
||||||
|
|
||||||
let hasNullValue = false;
|
let hasError = false;
|
||||||
|
|
||||||
chartData.forEach((chartDataItem) => {
|
chartData.forEach((chartDataItem) => {
|
||||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
||||||
hasNullValue = true;
|
hasError = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasNullValue) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
@ -131,6 +91,7 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json({
|
return <any>res.json({
|
||||||
|
hasError,
|
||||||
chart: chartData,
|
chart: chartData,
|
||||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||||
@ -140,7 +101,7 @@ export class PortfolioController {
|
|||||||
@Get('details')
|
@Get('details')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<PortfolioDetails> {
|
): Promise<PortfolioDetails> {
|
||||||
@ -152,6 +113,8 @@ export class PortfolioController {
|
|||||||
return <any>res.json({ accounts: {}, holdings: {} });
|
return <any>res.json({ accounts: {}, holdings: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hasError = false;
|
||||||
|
|
||||||
const { accounts, holdings, hasErrors } =
|
const { accounts, holdings, hasErrors } =
|
||||||
await this.portfolioService.getDetails(
|
await this.portfolioService.getDetails(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
@ -160,7 +123,7 @@ export class PortfolioController {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||||
res.status(StatusCodes.ACCEPTED);
|
hasError = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@ -198,43 +161,74 @@ export class PortfolioController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json({ accounts, holdings });
|
return <any>res.json({ accounts, hasError, holdings });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('investments')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getInvestments(
|
||||||
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
|
@Res() res: Response
|
||||||
|
): Promise<PortfolioInvestments> {
|
||||||
|
if (
|
||||||
|
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||||
|
this.request.user.subscription.type === 'Basic'
|
||||||
|
) {
|
||||||
|
res.status(StatusCodes.FORBIDDEN);
|
||||||
|
return <any>res.json({});
|
||||||
|
}
|
||||||
|
|
||||||
|
let investments = await this.portfolioService.getInvestments(
|
||||||
|
impersonationId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
impersonationId ||
|
||||||
|
this.userService.isRestrictedView(this.request.user)
|
||||||
|
) {
|
||||||
|
const maxInvestment = investments.reduce(
|
||||||
|
(investment, item) => Math.max(investment, item.investment),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
investments = investments.map((item) => ({
|
||||||
|
date: item.date,
|
||||||
|
investment: item.investment / maxInvestment
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return <any>res.json({ firstOrderDate: investments[0]?.date, investments });
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPerformance(
|
public async getPerformance(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<PortfolioPerformance> {
|
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||||
const performanceInformation = await this.portfolioService.getPerformance(
|
const performanceInformation = await this.portfolioService.getPerformance(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
range
|
range
|
||||||
);
|
);
|
||||||
|
|
||||||
if (performanceInformation?.hasErrors) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
let performance = performanceInformation.performance;
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
performance = nullifyValuesInObject(performance, [
|
performanceInformation.performance = nullifyValuesInObject(
|
||||||
'currentGrossPerformance',
|
performanceInformation.performance,
|
||||||
'currentValue'
|
['currentGrossPerformance', 'currentValue']
|
||||||
]);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(performance);
|
return <any>res.json(performanceInformation);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('positions')
|
@Get('positions')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPositions(
|
public async getPositions(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<PortfolioPositions> {
|
): Promise<PortfolioPositions> {
|
||||||
@ -243,10 +237,6 @@ export class PortfolioController {
|
|||||||
range
|
range
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result?.hasErrors) {
|
|
||||||
res.status(StatusCodes.ACCEPTED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationId ||
|
impersonationId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
@ -340,6 +330,7 @@ export class PortfolioController {
|
|||||||
'currentGrossPerformance',
|
'currentGrossPerformance',
|
||||||
'currentNetPerformance',
|
'currentNetPerformance',
|
||||||
'currentValue',
|
'currentValue',
|
||||||
|
'dividend',
|
||||||
'fees',
|
'fees',
|
||||||
'netWorth',
|
'netWorth',
|
||||||
'totalBuy',
|
'totalBuy',
|
||||||
@ -353,7 +344,7 @@ export class PortfolioController {
|
|||||||
@Get('position/:symbol')
|
@Get('position/:symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(
|
public async getPosition(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Param('symbol') symbol
|
@Param('symbol') symbol
|
||||||
): Promise<PortfolioPositionDetail> {
|
): Promise<PortfolioPositionDetail> {
|
||||||
let position = await this.portfolioService.getPosition(
|
let position = await this.portfolioService.getPosition(
|
||||||
@ -370,6 +361,7 @@ export class PortfolioController {
|
|||||||
'grossPerformance',
|
'grossPerformance',
|
||||||
'investment',
|
'investment',
|
||||||
'netPerformance',
|
'netPerformance',
|
||||||
|
'orders',
|
||||||
'quantity',
|
'quantity',
|
||||||
'value'
|
'value'
|
||||||
]);
|
]);
|
||||||
@ -387,7 +379,7 @@ export class PortfolioController {
|
|||||||
@Get('report')
|
@Get('report')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getReport(
|
public async getReport(
|
||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId: string,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<PortfolioReport> {
|
): Promise<PortfolioReport> {
|
||||||
if (
|
if (
|
||||||
|
@ -6,7 +6,6 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol
|
|||||||
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
|
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
|
||||||
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
|
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
|
||||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
|
||||||
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||||
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
|
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
|
||||||
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
||||||
@ -21,11 +20,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
|
|||||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
|
||||||
UNKNOWN_KEY,
|
|
||||||
baseCurrency,
|
|
||||||
ghostfolioCashSymbol
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
Accounts,
|
Accounts,
|
||||||
@ -60,7 +55,7 @@ import {
|
|||||||
subDays,
|
subDays,
|
||||||
subYears
|
subYears
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty, sortBy } from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
HistoricalDataContainer,
|
HistoricalDataContainer,
|
||||||
@ -155,12 +150,33 @@ export class PortfolioService {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return portfolioCalculator.getInvestments().map((item) => {
|
const investments = portfolioCalculator.getInvestments().map((item) => {
|
||||||
return {
|
return {
|
||||||
date: item.date,
|
date: item.date,
|
||||||
investment: item.investment.toNumber()
|
investment: item.investment.toNumber()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add investment of today
|
||||||
|
const investmentOfToday = investments.filter((investment) => {
|
||||||
|
return investment.date === format(new Date(), DATE_FORMAT);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (investmentOfToday.length <= 0) {
|
||||||
|
const pastInvestments = investments.filter((investment) => {
|
||||||
|
return isBefore(parseDate(investment.date), new Date());
|
||||||
|
});
|
||||||
|
const lastInvestment = pastInvestments[pastInvestments.length - 1];
|
||||||
|
|
||||||
|
investments.push({
|
||||||
|
date: format(new Date(), DATE_FORMAT),
|
||||||
|
investment: lastInvestment?.investment ?? 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy(investments, (investment) => {
|
||||||
|
return investment.date;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getChart(
|
public async getChart(
|
||||||
@ -393,6 +409,7 @@ export class PortfolioService {
|
|||||||
name: undefined,
|
name: undefined,
|
||||||
netPerformance: undefined,
|
netPerformance: undefined,
|
||||||
netPerformancePercent: undefined,
|
netPerformancePercent: undefined,
|
||||||
|
orders: [],
|
||||||
quantity: undefined,
|
quantity: undefined,
|
||||||
symbol: aSymbol,
|
symbol: aSymbol,
|
||||||
transactionCount: undefined,
|
transactionCount: undefined,
|
||||||
@ -405,17 +422,21 @@ export class PortfolioService {
|
|||||||
const positionCurrency = orders[0].currency;
|
const positionCurrency = orders[0].currency;
|
||||||
const name = orders[0].SymbolProfile?.name ?? '';
|
const name = orders[0].SymbolProfile?.name ?? '';
|
||||||
|
|
||||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
const portfolioOrders: PortfolioOrder[] = orders
|
||||||
currency: order.currency,
|
.filter((order) => {
|
||||||
dataSource: order.dataSource,
|
return order.type === 'BUY' || order.type === 'SELL';
|
||||||
date: format(order.date, DATE_FORMAT),
|
})
|
||||||
fee: new Big(order.fee),
|
.map((order) => ({
|
||||||
name: order.SymbolProfile?.name,
|
currency: order.currency,
|
||||||
quantity: new Big(order.quantity),
|
dataSource: order.dataSource,
|
||||||
symbol: order.symbol,
|
date: format(order.date, DATE_FORMAT),
|
||||||
type: <OrderType>order.type,
|
fee: new Big(order.fee),
|
||||||
unitPrice: new Big(order.unitPrice)
|
name: order.SymbolProfile?.name,
|
||||||
}));
|
quantity: new Big(order.quantity),
|
||||||
|
symbol: order.symbol,
|
||||||
|
type: order.type,
|
||||||
|
unitPrice: new Big(order.unitPrice)
|
||||||
|
}));
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
this.currentRateService,
|
this.currentRateService,
|
||||||
@ -447,17 +468,17 @@ export class PortfolioService {
|
|||||||
// Convert investment, gross and net performance to currency of user
|
// Convert investment, gross and net performance to currency of user
|
||||||
const userCurrency = this.request.user.Settings.currency;
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
const investment = this.exchangeRateDataService.toCurrency(
|
const investment = this.exchangeRateDataService.toCurrency(
|
||||||
position.investment.toNumber(),
|
position.investment?.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
const grossPerformance = this.exchangeRateDataService.toCurrency(
|
const grossPerformance = this.exchangeRateDataService.toCurrency(
|
||||||
position.grossPerformance.toNumber(),
|
position.grossPerformance?.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
const netPerformance = this.exchangeRateDataService.toCurrency(
|
const netPerformance = this.exchangeRateDataService.toCurrency(
|
||||||
position.netPerformance.toNumber(),
|
position.netPerformance?.toNumber(),
|
||||||
currency,
|
currency,
|
||||||
userCurrency
|
userCurrency
|
||||||
);
|
);
|
||||||
@ -526,6 +547,7 @@ export class PortfolioService {
|
|||||||
minPrice,
|
minPrice,
|
||||||
name,
|
name,
|
||||||
netPerformance,
|
netPerformance,
|
||||||
|
orders,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
averagePrice: averagePrice.toNumber(),
|
averagePrice: averagePrice.toNumber(),
|
||||||
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
||||||
@ -583,6 +605,7 @@ export class PortfolioService {
|
|||||||
maxPrice,
|
maxPrice,
|
||||||
minPrice,
|
minPrice,
|
||||||
name,
|
name,
|
||||||
|
orders,
|
||||||
averagePrice: 0,
|
averagePrice: 0,
|
||||||
currency: currentData[aSymbol]?.currency,
|
currency: currentData[aSymbol]?.currency,
|
||||||
firstBuyDate: undefined,
|
firstBuyDate: undefined,
|
||||||
@ -660,7 +683,9 @@ export class PortfolioService {
|
|||||||
grossPerformancePercentage:
|
grossPerformancePercentage:
|
||||||
position.grossPerformancePercentage?.toNumber() ?? null,
|
position.grossPerformancePercentage?.toNumber() ?? null,
|
||||||
investment: new Big(position.investment).toNumber(),
|
investment: new Big(position.investment).toNumber(),
|
||||||
marketState: dataProviderResponses[position.symbol].marketState,
|
marketState:
|
||||||
|
dataProviderResponses[position.symbol]?.marketState ??
|
||||||
|
MarketState.delayed,
|
||||||
name: symbolProfileMap[position.symbol].name,
|
name: symbolProfileMap[position.symbol].name,
|
||||||
netPerformance: position.netPerformance?.toNumber() ?? null,
|
netPerformance: position.netPerformance?.toNumber() ?? null,
|
||||||
netPerformancePercentage:
|
netPerformancePercentage:
|
||||||
@ -693,9 +718,7 @@ export class PortfolioService {
|
|||||||
currentGrossPerformancePercent: 0,
|
currentGrossPerformancePercent: 0,
|
||||||
currentNetPerformance: 0,
|
currentNetPerformance: 0,
|
||||||
currentNetPerformancePercent: 0,
|
currentNetPerformancePercent: 0,
|
||||||
currentValue: 0,
|
currentValue: 0
|
||||||
isAllTimeHigh: false,
|
|
||||||
isAllTimeLow: false
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -728,29 +751,11 @@ export class PortfolioService {
|
|||||||
currentGrossPerformancePercent,
|
currentGrossPerformancePercent,
|
||||||
currentNetPerformance,
|
currentNetPerformance,
|
||||||
currentNetPerformancePercent,
|
currentNetPerformancePercent,
|
||||||
currentValue,
|
currentValue
|
||||||
isAllTimeHigh: true, // TODO
|
|
||||||
isAllTimeLow: false // TODO
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
|
||||||
return orders
|
|
||||||
.filter((order) => {
|
|
||||||
// Filter out all orders before given date
|
|
||||||
return isBefore(date, new Date(order.date));
|
|
||||||
})
|
|
||||||
.map((order) => {
|
|
||||||
return this.exchangeRateDataService.toCurrency(
|
|
||||||
order.fee,
|
|
||||||
order.currency,
|
|
||||||
this.request.user.Settings.currency
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.reduce((previous, current) => previous + current, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||||
const currency = this.request.user.Settings.currency;
|
const currency = this.request.user.Settings.currency;
|
||||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||||
@ -831,7 +836,7 @@ export class PortfolioService {
|
|||||||
new FeeRatioInitialInvestment(
|
new FeeRatioInitialInvestment(
|
||||||
this.exchangeRateDataService,
|
this.exchangeRateDataService,
|
||||||
currentPositions.totalInvestment.toNumber(),
|
currentPositions.totalInvestment.toNumber(),
|
||||||
this.getFees(orders)
|
this.getFees(orders).toNumber()
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
{ baseCurrency: currency }
|
{ baseCurrency: currency }
|
||||||
@ -850,12 +855,15 @@ export class PortfolioService {
|
|||||||
userId,
|
userId,
|
||||||
currency
|
currency
|
||||||
);
|
);
|
||||||
const orders = await this.orderService.getOrders({ userId });
|
const orders = await this.orderService.getOrders({
|
||||||
const fees = this.getFees(orders);
|
userId
|
||||||
|
});
|
||||||
|
const dividend = this.getDividend(orders).toNumber();
|
||||||
|
const fees = this.getFees(orders).toNumber();
|
||||||
const firstOrderDate = orders[0]?.date;
|
const firstOrderDate = orders[0]?.date;
|
||||||
|
|
||||||
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
|
const totalBuy = this.getTotalByType(orders, currency, 'BUY');
|
||||||
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
|
const totalSell = this.getTotalByType(orders, currency, 'SELL');
|
||||||
|
|
||||||
const committedFunds = new Big(totalBuy).sub(totalSell);
|
const committedFunds = new Big(totalBuy).sub(totalSell);
|
||||||
|
|
||||||
@ -865,14 +873,17 @@ export class PortfolioService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...performanceInformation.performance,
|
...performanceInformation.performance,
|
||||||
|
dividend,
|
||||||
fees,
|
fees,
|
||||||
firstOrderDate,
|
firstOrderDate,
|
||||||
netWorth,
|
netWorth,
|
||||||
|
totalBuy,
|
||||||
|
totalSell,
|
||||||
cash: balance,
|
cash: balance,
|
||||||
committedFunds: committedFunds.toNumber(),
|
committedFunds: committedFunds.toNumber(),
|
||||||
ordersCount: orders.length,
|
ordersCount: orders.filter((order) => {
|
||||||
totalBuy: totalBuy,
|
return order.type === 'BUY' || order.type === 'SELL';
|
||||||
totalSell: totalSell
|
}).length
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -945,6 +956,47 @@ export class PortfolioService {
|
|||||||
return cashPositions;
|
return cashPositions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
|
||||||
|
return orders
|
||||||
|
.filter((order) => {
|
||||||
|
// Filter out all orders before given date and type dividend
|
||||||
|
return (
|
||||||
|
isBefore(date, new Date(order.date)) &&
|
||||||
|
order.type === TypeOfOrder.DIVIDEND
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((order) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||||
|
order.currency,
|
||||||
|
this.request.user.Settings.currency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
||||||
|
return orders
|
||||||
|
.filter((order) => {
|
||||||
|
// Filter out all orders before given date
|
||||||
|
return isBefore(date, new Date(order.date));
|
||||||
|
})
|
||||||
|
.map((order) => {
|
||||||
|
return this.exchangeRateDataService.toCurrency(
|
||||||
|
order.fee,
|
||||||
|
order.currency,
|
||||||
|
this.request.user.Settings.currency
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.reduce(
|
||||||
|
(previous, current) => new Big(previous).plus(current),
|
||||||
|
new Big(0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||||
switch (aDateRange) {
|
switch (aDateRange) {
|
||||||
case '1d':
|
case '1d':
|
||||||
@ -973,7 +1025,11 @@ export class PortfolioService {
|
|||||||
transactionPoints: TransactionPoint[];
|
transactionPoints: TransactionPoint[];
|
||||||
orders: OrderWithAccount[];
|
orders: OrderWithAccount[];
|
||||||
}> {
|
}> {
|
||||||
const orders = await this.orderService.getOrders({ includeDrafts, userId });
|
const orders = await this.orderService.getOrders({
|
||||||
|
includeDrafts,
|
||||||
|
userId,
|
||||||
|
types: ['BUY', 'SELL']
|
||||||
|
});
|
||||||
|
|
||||||
if (orders.length <= 0) {
|
if (orders.length <= 0) {
|
||||||
return { transactionPoints: [], orders: [] };
|
return { transactionPoints: [], orders: [] };
|
||||||
@ -994,7 +1050,7 @@ export class PortfolioService {
|
|||||||
name: order.SymbolProfile?.name,
|
name: order.SymbolProfile?.name,
|
||||||
quantity: new Big(order.quantity),
|
quantity: new Big(order.quantity),
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
type: <OrderType>order.type,
|
type: order.type,
|
||||||
unitPrice: new Big(
|
unitPrice: new Big(
|
||||||
this.exchangeRateDataService.toCurrency(
|
this.exchangeRateDataService.toCurrency(
|
||||||
order.unitPrice,
|
order.unitPrice,
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||||
|
import { PROPERTY_COUPONS } from '@ghostfolio/common/config';
|
||||||
|
import { Coupon } from '@ghostfolio/common/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
@ -14,6 +17,7 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
@ -22,16 +26,70 @@ import { SubscriptionService } from './subscription.service';
|
|||||||
export class SubscriptionController {
|
export class SubscriptionController {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly propertyService: PropertyService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
@Post('redeem-coupon')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async redeemCoupon(
|
||||||
|
@Body() { couponCode }: { couponCode: string },
|
||||||
|
@Res() res: Response
|
||||||
|
) {
|
||||||
|
if (!this.request.user) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let coupons =
|
||||||
|
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
|
||||||
|
[];
|
||||||
|
|
||||||
|
const isValid = coupons.some((coupon) => {
|
||||||
|
return coupon.code === couponCode;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.subscriptionService.createSubscription(this.request.user.id);
|
||||||
|
|
||||||
|
// Destroy coupon
|
||||||
|
coupons = coupons.filter((coupon) => {
|
||||||
|
return coupon.code !== couponCode;
|
||||||
|
});
|
||||||
|
await this.propertyService.put({
|
||||||
|
key: PROPERTY_COUPONS,
|
||||||
|
value: JSON.stringify(coupons)
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.log(
|
||||||
|
`Subscription for user '${this.request.user.id}' has been created with coupon`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.status(StatusCodes.OK);
|
||||||
|
|
||||||
|
return <any>res.json({
|
||||||
|
message: getReasonPhrase(StatusCodes.OK),
|
||||||
|
statusCode: StatusCodes.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Get('stripe/callback')
|
@Get('stripe/callback')
|
||||||
public async stripeCallback(@Req() req, @Res() res) {
|
public async stripeCallback(@Req() req, @Res() res) {
|
||||||
await this.subscriptionService.createSubscription(
|
const userId = await this.subscriptionService.createSubscriptionViaStripe(
|
||||||
req.query.checkoutSessionId
|
req.query.checkoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Logger.log(`Subscription for user '${userId}' has been created via Stripe`);
|
||||||
|
|
||||||
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { SubscriptionController } from './subscription.controller';
|
import { SubscriptionController } from './subscription.controller';
|
||||||
import { SubscriptionService } from './subscription.service';
|
import { SubscriptionService } from './subscription.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [],
|
imports: [PropertyModule],
|
||||||
controllers: [SubscriptionController],
|
controllers: [SubscriptionController],
|
||||||
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
||||||
exports: [SubscriptionService]
|
exports: [SubscriptionService]
|
||||||
|
@ -2,7 +2,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription } from '@prisma/client';
|
import { Subscription, User } from '@prisma/client';
|
||||||
import { addDays, isBefore } from 'date-fns';
|
import { addDays, isBefore } from 'date-fns';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
|
|
||||||
@ -64,26 +64,32 @@ export class SubscriptionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async createSubscription(aCheckoutSessionId: string) {
|
public async createSubscription(aUserId: string) {
|
||||||
|
await this.prismaService.subscription.create({
|
||||||
|
data: {
|
||||||
|
expiresAt: addDays(new Date(), 365),
|
||||||
|
User: {
|
||||||
|
connect: {
|
||||||
|
id: aUserId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
|
||||||
try {
|
try {
|
||||||
const session = await this.stripe.checkout.sessions.retrieve(
|
const session = await this.stripe.checkout.sessions.retrieve(
|
||||||
aCheckoutSessionId
|
aCheckoutSessionId
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.prismaService.subscription.create({
|
await this.createSubscription(session.client_reference_id);
|
||||||
data: {
|
|
||||||
expiresAt: addDays(new Date(), 365),
|
|
||||||
User: {
|
|
||||||
connect: {
|
|
||||||
id: session.client_reference_id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.stripe.customers.update(session.customer as string, {
|
await this.stripe.customers.update(session.customer as string, {
|
||||||
description: session.client_reference_id
|
description: session.client_reference_id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return session.client_reference_id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
@ -14,7 +15,7 @@ import { REQUEST } from '@nestjs/core';
|
|||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
import { isEmpty } from 'lodash';
|
import { isDate, isEmpty } from 'lodash';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||||
@ -36,8 +37,7 @@ export class SymbolController {
|
|||||||
@Query() { query = '' }
|
@Query() { query = '' }
|
||||||
): Promise<{ items: LookupItem[] }> {
|
): Promise<{ items: LookupItem[] }> {
|
||||||
try {
|
try {
|
||||||
const encodedQuery = encodeURIComponent(query.toLowerCase());
|
return this.symbolService.lookup(query.toLowerCase());
|
||||||
return this.symbolService.lookup(encodedQuery);
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||||
@ -78,4 +78,27 @@ export class SymbolController {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':dataSource/:symbol/:dateString')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherSymbolForDate(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('dateString') dateString: string,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<IDataProviderHistoricalResponse> {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
|
||||||
|
if (!isDate(date)) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
StatusCodes.BAD_REQUEST
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.symbolService.getForDate({
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import {
|
||||||
|
IDataGatheringItem,
|
||||||
|
IDataProviderHistoricalResponse
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { subDays } from 'date-fns';
|
import { format, subDays } from 'date-fns';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||||
@ -32,17 +36,17 @@ export class SymbolService {
|
|||||||
let historicalData: HistoricalDataItem[];
|
let historicalData: HistoricalDataItem[];
|
||||||
|
|
||||||
if (includeHistoricalData) {
|
if (includeHistoricalData) {
|
||||||
const days = 10;
|
const days = 30;
|
||||||
|
|
||||||
const marketData = await this.marketDataService.getRange({
|
const marketData = await this.marketDataService.getRange({
|
||||||
dateQuery: { gte: subDays(new Date(), days) },
|
dateQuery: { gte: subDays(new Date(), days) },
|
||||||
symbols: [dataGatheringItem.symbol]
|
symbols: [dataGatheringItem.symbol]
|
||||||
});
|
});
|
||||||
|
|
||||||
historicalData = marketData.map(({ date, marketPrice }) => {
|
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
||||||
return {
|
return {
|
||||||
date: date.toISOString(),
|
value,
|
||||||
value: marketPrice
|
date: date.toISOString()
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -58,6 +62,27 @@ export class SymbolService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getForDate({
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
date: Date;
|
||||||
|
symbol: string;
|
||||||
|
}): Promise<IDataProviderHistoricalResponse> {
|
||||||
|
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
|
[{ dataSource, symbol }],
|
||||||
|
date,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
marketPrice:
|
||||||
|
historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
const results: { items: LookupItem[] } = { items: [] };
|
const results: { items: LookupItem[] } = { items: [] };
|
||||||
|
|
||||||
@ -68,32 +93,6 @@ export class SymbolService {
|
|||||||
try {
|
try {
|
||||||
const { items } = await this.dataProviderService.search(aQuery);
|
const { items } = await this.dataProviderService.search(aQuery);
|
||||||
results.items = items;
|
results.items = items;
|
||||||
|
|
||||||
// Add custom symbols
|
|
||||||
const ghostfolioSymbolProfiles =
|
|
||||||
await this.prismaService.symbolProfile.findMany({
|
|
||||||
select: {
|
|
||||||
currency: true,
|
|
||||||
dataSource: true,
|
|
||||||
name: true,
|
|
||||||
symbol: true
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
AND: [
|
|
||||||
{
|
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
|
||||||
name: {
|
|
||||||
startsWith: aQuery
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
|
|
||||||
results.items.push(ghostfolioSymbolProfile);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(error);
|
Logger.error(error);
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
export enum OrderType {
|
|
||||||
CorporateAction = 'CORPORATE_ACTION',
|
|
||||||
Bonus = 'BONUS',
|
|
||||||
Buy = 'BUY',
|
|
||||||
Dividend = 'DIVIDEND',
|
|
||||||
Sell = 'SELL',
|
|
||||||
Split = 'SPLIT'
|
|
||||||
}
|
|
@ -1,8 +1,7 @@
|
|||||||
import { Account, SymbolProfile } from '@prisma/client';
|
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
import { IOrder } from '../services/interfaces/interfaces';
|
import { IOrder } from '../services/interfaces/interfaces';
|
||||||
import { OrderType } from './order-type';
|
|
||||||
|
|
||||||
export class Order {
|
export class Order {
|
||||||
private account: Account;
|
private account: Account;
|
||||||
@ -15,7 +14,7 @@ export class Order {
|
|||||||
private symbol: string;
|
private symbol: string;
|
||||||
private symbolProfile: SymbolProfile;
|
private symbolProfile: SymbolProfile;
|
||||||
private total: number;
|
private total: number;
|
||||||
private type: OrderType;
|
private type: TypeOfOrder;
|
||||||
private unitPrice: number;
|
private unitPrice: number;
|
||||||
|
|
||||||
public constructor(data: IOrder) {
|
public constructor(data: IOrder) {
|
||||||
|
@ -13,6 +13,7 @@ export class ConfigurationService {
|
|||||||
ACCESS_TOKEN_SALT: str(),
|
ACCESS_TOKEN_SALT: str(),
|
||||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||||
CACHE_TTL: num({ default: 1 }),
|
CACHE_TTL: num({ default: 1 }),
|
||||||
|
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||||
@ -25,8 +26,12 @@ export class ConfigurationService {
|
|||||||
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
||||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||||
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
||||||
|
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
||||||
|
GOOGLE_SHEETS_ID: str({ default: '' }),
|
||||||
|
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
||||||
JWT_SECRET_KEY: str({}),
|
JWT_SECRET_KEY: str({}),
|
||||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||||
|
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||||
PORT: port({ default: 3333 }),
|
PORT: port({ default: 3333 }),
|
||||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||||
REDIS_HOST: str({ default: 'localhost' }),
|
REDIS_HOST: str({ default: 'localhost' }),
|
||||||
|
@ -10,7 +10,7 @@ export class CryptocurrencyService {
|
|||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
|
||||||
public isCrypto(aSymbol = '') {
|
public isCryptocurrency(aSymbol = '') {
|
||||||
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
|
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
|
||||||
return this.getCryptocurrencies().includes(cryptocurrencySymbol);
|
return this.getCryptocurrencies().includes(cryptocurrencySymbol);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"1INCH": "1inch",
|
"1INCH": "1inch",
|
||||||
"ALGO": "Algorand",
|
"ALGO": "Algorand",
|
||||||
|
"ATOM": "Cosmos",
|
||||||
"AVAX": "Avalanche",
|
"AVAX": "Avalanche",
|
||||||
|
"DOT": "Polkadot",
|
||||||
"MATIC": "Polygon",
|
"MATIC": "Polygon",
|
||||||
"SHIB": "Shiba Inu"
|
"SHIB": "Shiba Inu",
|
||||||
|
"SOL": "Solana",
|
||||||
|
"UNI3": "Uniswap"
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
PROPERTY_LAST_DATA_GATHERING,
|
PROPERTY_LAST_DATA_GATHERING,
|
||||||
PROPERTY_LOCKED_DATA_GATHERING,
|
PROPERTY_LOCKED_DATA_GATHERING
|
||||||
ghostfolioFearAndGreedIndexSymbol
|
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
@ -17,7 +16,6 @@ import {
|
|||||||
subDays
|
subDays
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
|
|
||||||
import { ConfigurationService } from './configuration.service';
|
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||||
@ -29,7 +27,6 @@ export class DataGatheringService {
|
|||||||
private dataGatheringProgress: number;
|
private dataGatheringProgress: number;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
|
||||||
@Inject('DataEnhancers')
|
@Inject('DataEnhancers')
|
||||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
@ -181,6 +178,44 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async gatherSymbolForDate({
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
date: Date;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
|
[{ dataSource, symbol }],
|
||||||
|
date,
|
||||||
|
date
|
||||||
|
);
|
||||||
|
|
||||||
|
const marketPrice =
|
||||||
|
historicalData[symbol][format(date, DATE_FORMAT)].marketPrice;
|
||||||
|
|
||||||
|
if (marketPrice) {
|
||||||
|
return await this.prismaService.marketData.upsert({
|
||||||
|
create: {
|
||||||
|
dataSource,
|
||||||
|
date,
|
||||||
|
marketPrice,
|
||||||
|
symbol
|
||||||
|
},
|
||||||
|
update: { marketPrice },
|
||||||
|
where: { date_symbol: { date, symbol } }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error);
|
||||||
|
} finally {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
||||||
Logger.log('Profile data gathering has been started.');
|
Logger.log('Profile data gathering has been started.');
|
||||||
console.time('data-gathering-profile');
|
console.time('data-gathering-profile');
|
||||||
@ -207,7 +242,7 @@ export class DataGatheringService {
|
|||||||
try {
|
try {
|
||||||
currentData[symbol] = await dataEnhancer.enhance({
|
currentData[symbol] = await dataEnhancer.enhance({
|
||||||
response,
|
response,
|
||||||
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol
|
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
|
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
|
||||||
@ -299,16 +334,25 @@ export class DataGatheringService {
|
|||||||
?.marketPrice;
|
?.marketPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (lastMarketPrice) {
|
||||||
await this.prismaService.marketData.create({
|
try {
|
||||||
data: {
|
await this.prismaService.marketData.create({
|
||||||
dataSource,
|
data: {
|
||||||
symbol,
|
dataSource,
|
||||||
date: currentDate,
|
symbol,
|
||||||
marketPrice: lastMarketPrice
|
date: currentDate,
|
||||||
}
|
marketPrice: lastMarketPrice
|
||||||
});
|
}
|
||||||
} catch {}
|
});
|
||||||
|
} catch {}
|
||||||
|
} else {
|
||||||
|
Logger.warn(
|
||||||
|
`Failed to gather data for symbol ${symbol} at ${format(
|
||||||
|
currentDate,
|
||||||
|
DATE_FORMAT
|
||||||
|
)}.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Count month one up for iteration
|
// Count month one up for iteration
|
||||||
currentDate = new Date(
|
currentDate = new Date(
|
||||||
@ -410,11 +454,7 @@ export class DataGatheringService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||||
...this.getBenchmarksToGather(startDate),
|
|
||||||
...currencyPairsToGather,
|
|
||||||
...symbolProfilesToGather
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async reset() {
|
public async reset() {
|
||||||
@ -430,23 +470,27 @@ export class DataGatheringService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
|
|
||||||
const benchmarksToGather: IDataGatheringItem[] = [];
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
|
||||||
benchmarksToGather.push({
|
|
||||||
dataSource: DataSource.RAKUTEN,
|
|
||||||
date: startDate,
|
|
||||||
symbol: ghostfolioFearAndGreedIndexSymbol
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return benchmarksToGather;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||||
const startDate = subDays(resetHours(new Date()), 7);
|
const startDate = subDays(resetHours(new Date()), 7);
|
||||||
|
|
||||||
|
// Only consider symbols with incomplete market data for the last
|
||||||
|
// 7 days
|
||||||
|
const symbolsToGather = (
|
||||||
|
await this.prismaService.marketData.groupBy({
|
||||||
|
_count: true,
|
||||||
|
by: ['symbol'],
|
||||||
|
where: {
|
||||||
|
date: { gt: startDate }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.filter((group) => {
|
||||||
|
return group._count < 6;
|
||||||
|
})
|
||||||
|
.map((group) => {
|
||||||
|
return group.symbol;
|
||||||
|
});
|
||||||
|
|
||||||
const symbolProfilesToGather = (
|
const symbolProfilesToGather = (
|
||||||
await this.prismaService.symbolProfile.findMany({
|
await this.prismaService.symbolProfile.findMany({
|
||||||
orderBy: [{ symbol: 'asc' }],
|
orderBy: [{ symbol: 'asc' }],
|
||||||
@ -456,15 +500,22 @@ export class DataGatheringService {
|
|||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).map((symbolProfile) => {
|
)
|
||||||
return {
|
.filter(({ symbol }) => {
|
||||||
...symbolProfile,
|
return symbolsToGather.includes(symbol);
|
||||||
date: startDate
|
})
|
||||||
};
|
.map((symbolProfile) => {
|
||||||
});
|
return {
|
||||||
|
...symbolProfile,
|
||||||
|
date: startDate
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const currencyPairsToGather = this.exchangeRateDataService
|
const currencyPairsToGather = this.exchangeRateDataService
|
||||||
.getCurrencyPairs()
|
.getCurrencyPairs()
|
||||||
|
.filter(({ symbol }) => {
|
||||||
|
return symbolsToGather.includes(symbol);
|
||||||
|
})
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
dataSource,
|
dataSource,
|
||||||
@ -473,30 +524,22 @@ export class DataGatheringService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||||
...this.getBenchmarksToGather(startDate),
|
|
||||||
...currencyPairsToGather,
|
|
||||||
...symbolProfilesToGather
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
||||||
const startDate = subDays(resetHours(new Date()), 7);
|
|
||||||
|
|
||||||
const distinctOrders = await this.prismaService.order.findMany({
|
const distinctOrders = await this.prismaService.order.findMany({
|
||||||
distinct: ['symbol'],
|
distinct: ['symbol'],
|
||||||
orderBy: [{ symbol: 'asc' }],
|
orderBy: [{ symbol: 'asc' }],
|
||||||
select: { dataSource: true, symbol: true }
|
select: { dataSource: true, symbol: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter(
|
return distinctOrders.filter((distinctOrder) => {
|
||||||
(distinctOrder) => {
|
return (
|
||||||
return (
|
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
||||||
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
distinctOrder.dataSource !== DataSource.RAKUTEN
|
||||||
distinctOrder.dataSource !== DataSource.RAKUTEN
|
);
|
||||||
);
|
});
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async isDataGatheringNeeded() {
|
private async isDataGatheringNeeded() {
|
||||||
|
@ -88,13 +88,13 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
return DataSource.ALPHA_VANTAGE;
|
return DataSource.ALPHA_VANTAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
const result = await this.alphaVantage.data.search(aSymbol);
|
const result = await this.alphaVantage.data.search(aQuery);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
items: result?.bestMatches?.map((bestMatch) => {
|
items: result?.bestMatches?.map((bestMatch) => {
|
||||||
return {
|
return {
|
||||||
dataSource: DataSource.ALPHA_VANTAGE,
|
dataSource: this.getName(),
|
||||||
name: bestMatch['2. name'],
|
name: bestMatch['2. name'],
|
||||||
symbol: bestMatch['1. symbol']
|
symbol: bestMatch['1. symbol']
|
||||||
};
|
};
|
||||||
|
@ -7,6 +7,9 @@ const getJSON = bent('json');
|
|||||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||||
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
||||||
private static countries = require('countries-list/dist/countries.json');
|
private static countries = require('countries-list/dist/countries.json');
|
||||||
|
private static countriesMapping = {
|
||||||
|
'Russian Federation': 'Russia'
|
||||||
|
};
|
||||||
private static sectorsMapping = {
|
private static sectorsMapping = {
|
||||||
'Consumer Discretionary': 'Consumer Cyclical',
|
'Consumer Discretionary': 'Consumer Cyclical',
|
||||||
'Consumer Defensive': 'Consumer Staples',
|
'Consumer Defensive': 'Consumer Staples',
|
||||||
@ -45,7 +48,11 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
for (const [key, country] of Object.entries<any>(
|
for (const [key, country] of Object.entries<any>(
|
||||||
TrackinsightDataEnhancerService.countries
|
TrackinsightDataEnhancerService.countries
|
||||||
)) {
|
)) {
|
||||||
if (country.name === name) {
|
if (
|
||||||
|
country.name === name ||
|
||||||
|
country.name ===
|
||||||
|
TrackinsightDataEnhancerService.countriesMapping[name]
|
||||||
|
) {
|
||||||
countryCode = key;
|
countryCode = key;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||||
|
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
@ -21,12 +22,14 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
DataProviderService,
|
DataProviderService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
|
GoogleSheetsService,
|
||||||
RakutenRapidApiService,
|
RakutenRapidApiService,
|
||||||
YahooFinanceService,
|
YahooFinanceService,
|
||||||
{
|
{
|
||||||
inject: [
|
inject: [
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
GhostfolioScraperApiService,
|
GhostfolioScraperApiService,
|
||||||
|
GoogleSheetsService,
|
||||||
RakutenRapidApiService,
|
RakutenRapidApiService,
|
||||||
YahooFinanceService
|
YahooFinanceService
|
||||||
],
|
],
|
||||||
@ -34,11 +37,13 @@ import { DataProviderService } from './data-provider.service';
|
|||||||
useFactory: (
|
useFactory: (
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
ghostfolioScraperApiService,
|
ghostfolioScraperApiService,
|
||||||
|
googleSheetsService,
|
||||||
rakutenRapidApiService,
|
rakutenRapidApiService,
|
||||||
yahooFinanceService
|
yahooFinanceService
|
||||||
) => [
|
) => [
|
||||||
alphaVantageService,
|
alphaVantageService,
|
||||||
ghostfolioScraperApiService,
|
ghostfolioScraperApiService,
|
||||||
|
googleSheetsService,
|
||||||
rakutenRapidApiService,
|
rakutenRapidApiService,
|
||||||
yahooFinanceService
|
yahooFinanceService
|
||||||
]
|
]
|
||||||
|
@ -149,13 +149,13 @@ export class DataProviderService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||||
let lookupItems: LookupItem[] = [];
|
let lookupItems: LookupItem[] = [];
|
||||||
|
|
||||||
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
||||||
promises.push(
|
promises.push(
|
||||||
this.getDataProvider(DataSource[dataSource]).search(aSymbol)
|
this.getDataProvider(DataSource[dataSource]).search(aQuery)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -176,7 +176,7 @@ export class DataProviderService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public getPrimaryDataSource(): DataSource {
|
public getPrimaryDataSource(): DataSource {
|
||||||
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
|
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
|
||||||
}
|
}
|
||||||
|
|
||||||
private getDataProvider(providerName: DataSource) {
|
private getDataProvider(providerName: DataSource) {
|
||||||
|
@ -1,4 +1,10 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
|
import {
|
||||||
|
IDataProviderHistoricalResponse,
|
||||||
|
IDataProviderResponse,
|
||||||
|
MarketState
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
@ -13,13 +19,6 @@ import * as bent from 'bent';
|
|||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import {
|
|
||||||
IDataProviderHistoricalResponse,
|
|
||||||
IDataProviderResponse,
|
|
||||||
MarketState
|
|
||||||
} from '../../interfaces/interfaces';
|
|
||||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||||
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
||||||
@ -59,7 +58,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
[symbol]: {
|
[symbol]: {
|
||||||
marketPrice,
|
marketPrice,
|
||||||
currency: symbolProfile?.currency,
|
currency: symbolProfile?.currency,
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
dataSource: this.getName(),
|
||||||
marketState: MarketState.delayed
|
marketState: MarketState.delayed
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -116,8 +115,35 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
return DataSource.GHOSTFOLIO;
|
return DataSource.GHOSTFOLIO;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
return { items: [] };
|
const items = await this.prismaService.symbolProfile.findMany({
|
||||||
|
select: {
|
||||||
|
currency: true,
|
||||||
|
dataSource: true,
|
||||||
|
name: true,
|
||||||
|
symbol: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
name: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
symbol: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
private extractNumberFromString(aString: string): number {
|
private extractNumberFromString(aString: string): number {
|
||||||
|
@ -0,0 +1,172 @@
|
|||||||
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
|
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||||
|
import {
|
||||||
|
IDataProviderHistoricalResponse,
|
||||||
|
IDataProviderResponse,
|
||||||
|
MarketState
|
||||||
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { GoogleSpreadsheet } from 'google-spreadsheet';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GoogleSheetsService implements DataProviderInterface {
|
||||||
|
public constructor(
|
||||||
|
private readonly configurationService: ConfigurationService,
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public canHandle(symbol: string) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get(
|
||||||
|
aSymbols: string[]
|
||||||
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
|
if (aSymbols.length <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [symbol] = aSymbols;
|
||||||
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||||
|
[symbol]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sheet = await this.getSheet({
|
||||||
|
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
|
||||||
|
symbol
|
||||||
|
});
|
||||||
|
const marketPrice = parseFloat(
|
||||||
|
(await sheet.getCellByA1('B1').value) as string
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
[symbol]: {
|
||||||
|
marketPrice,
|
||||||
|
currency: symbolProfile?.currency,
|
||||||
|
dataSource: this.getName(),
|
||||||
|
marketState: MarketState.delayed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getHistorical(
|
||||||
|
aSymbols: string[],
|
||||||
|
aGranularity: Granularity = 'day',
|
||||||
|
from: Date,
|
||||||
|
to: Date
|
||||||
|
): Promise<{
|
||||||
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
|
}> {
|
||||||
|
if (aSymbols.length <= 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [symbol] = aSymbols;
|
||||||
|
|
||||||
|
const sheet = await this.getSheet({
|
||||||
|
symbol,
|
||||||
|
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = await sheet.getRows();
|
||||||
|
|
||||||
|
const historicalData: {
|
||||||
|
[date: string]: IDataProviderHistoricalResponse;
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
rows
|
||||||
|
.filter((row, index) => {
|
||||||
|
return index >= 1;
|
||||||
|
})
|
||||||
|
.forEach((row) => {
|
||||||
|
const date = new Date(row._rawData[0]);
|
||||||
|
const close = parseFloat(row._rawData[1]);
|
||||||
|
|
||||||
|
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
[symbol]: historicalData
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getName(): DataSource {
|
||||||
|
return DataSource.GOOGLE_SHEETS;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
|
const items = await this.prismaService.symbolProfile.findMany({
|
||||||
|
select: {
|
||||||
|
currency: true,
|
||||||
|
dataSource: true,
|
||||||
|
name: true,
|
||||||
|
symbol: true
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
OR: [
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
name: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataSource: this.getName(),
|
||||||
|
symbol: {
|
||||||
|
mode: 'insensitive',
|
||||||
|
startsWith: aQuery
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSheet({
|
||||||
|
sheetId,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
sheetId: string;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
const doc = new GoogleSpreadsheet(sheetId);
|
||||||
|
|
||||||
|
await doc.useServiceAccountAuth({
|
||||||
|
client_email: this.configurationService.get('GOOGLE_SHEETS_ACCOUNT'),
|
||||||
|
private_key: this.configurationService
|
||||||
|
.get('GOOGLE_SHEETS_PRIVATE_KEY')
|
||||||
|
.replace(/\\n/g, '\n')
|
||||||
|
});
|
||||||
|
|
||||||
|
await doc.loadInfo();
|
||||||
|
|
||||||
|
const sheet = doc.sheetsByTitle[symbol];
|
||||||
|
|
||||||
|
await sheet.loadCells();
|
||||||
|
|
||||||
|
return sheet;
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,10 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from '../../interfaces/interfaces';
|
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface DataProviderInterface {
|
export interface DataProviderInterface {
|
||||||
canHandle(symbol: string): boolean;
|
canHandle(symbol: string): boolean;
|
||||||
@ -23,5 +22,5 @@ export interface DataProviderInterface {
|
|||||||
|
|
||||||
getName(): DataSource;
|
getName(): DataSource;
|
||||||
|
|
||||||
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
search(aQuery: string): Promise<{ items: LookupItem[] }>;
|
||||||
}
|
}
|
||||||
|
@ -45,7 +45,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
return {
|
return {
|
||||||
[ghostfolioFearAndGreedIndexSymbol]: {
|
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||||
currency: undefined,
|
currency: undefined,
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: this.getName(),
|
||||||
marketPrice: fgi.now.value,
|
marketPrice: fgi.now.value,
|
||||||
marketState: MarketState.open,
|
marketState: MarketState.open,
|
||||||
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
|
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
|
||||||
@ -85,7 +85,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
symbol,
|
symbol,
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: this.getName(),
|
||||||
date: subWeeks(getToday(), 1),
|
date: subWeeks(getToday(), 1),
|
||||||
marketPrice: fgi.oneWeekAgo.value
|
marketPrice: fgi.oneWeekAgo.value
|
||||||
}
|
}
|
||||||
@ -94,7 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
symbol,
|
symbol,
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: this.getName(),
|
||||||
date: subMonths(getToday(), 1),
|
date: subMonths(getToday(), 1),
|
||||||
marketPrice: fgi.oneMonthAgo.value
|
marketPrice: fgi.oneMonthAgo.value
|
||||||
}
|
}
|
||||||
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
await this.prismaService.marketData.create({
|
await this.prismaService.marketData.create({
|
||||||
data: {
|
data: {
|
||||||
symbol,
|
symbol,
|
||||||
dataSource: DataSource.RAKUTEN,
|
dataSource: this.getName(),
|
||||||
date: subYears(getToday(), 1),
|
date: subYears(getToday(), 1),
|
||||||
marketPrice: fgi.oneYearAgo.value
|
marketPrice: fgi.oneYearAgo.value
|
||||||
}
|
}
|
||||||
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
return DataSource.RAKUTEN;
|
return DataSource.RAKUTEN;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
return { items: [] };
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -0,0 +1,60 @@
|
|||||||
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||||
|
|
||||||
|
import { YahooFinanceService } from './yahoo-finance.service';
|
||||||
|
|
||||||
|
jest.mock(
|
||||||
|
'@ghostfolio/api/services/cryptocurrency/cryptocurrency.service',
|
||||||
|
() => {
|
||||||
|
return {
|
||||||
|
CryptocurrencyService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
isCryptocurrency: (symbol: string) => {
|
||||||
|
switch (symbol) {
|
||||||
|
case 'BTCUSD':
|
||||||
|
return true;
|
||||||
|
case 'DOGEUSD':
|
||||||
|
return true;
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
describe('YahooFinanceService', () => {
|
||||||
|
let cryptocurrencyService: CryptocurrencyService;
|
||||||
|
let yahooFinanceService: YahooFinanceService;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
cryptocurrencyService = new CryptocurrencyService();
|
||||||
|
|
||||||
|
yahooFinanceService = new YahooFinanceService(cryptocurrencyService);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('convertFromYahooFinanceSymbol', async () => {
|
||||||
|
expect(
|
||||||
|
await yahooFinanceService.convertFromYahooFinanceSymbol('BRK-B')
|
||||||
|
).toEqual('BRK-B');
|
||||||
|
expect(
|
||||||
|
await yahooFinanceService.convertFromYahooFinanceSymbol('BTC-USD')
|
||||||
|
).toEqual('BTCUSD');
|
||||||
|
expect(
|
||||||
|
await yahooFinanceService.convertFromYahooFinanceSymbol('EURUSD=X')
|
||||||
|
).toEqual('EURUSD');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('convertToYahooFinanceSymbol', async () => {
|
||||||
|
expect(
|
||||||
|
await yahooFinanceService.convertToYahooFinanceSymbol('BTCUSD')
|
||||||
|
).toEqual('BTC-USD');
|
||||||
|
expect(
|
||||||
|
await yahooFinanceService.convertToYahooFinanceSymbol('DOGEUSD')
|
||||||
|
).toEqual('DOGE-USD');
|
||||||
|
expect(
|
||||||
|
await yahooFinanceService.convertToYahooFinanceSymbol('USDCHF')
|
||||||
|
).toEqual('USDCHF=X');
|
||||||
|
});
|
||||||
|
});
|
@ -1,6 +1,6 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
@ -8,7 +8,7 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
|||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import { countries } from 'countries-list';
|
import { countries } from 'countries-list';
|
||||||
import { format } from 'date-fns';
|
import { addDays, format, isSameDay } from 'date-fns';
|
||||||
import * as yahooFinance from 'yahoo-finance';
|
import * as yahooFinance from 'yahoo-finance';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -35,6 +35,44 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
||||||
|
const symbol = aYahooFinanceSymbol.replace(
|
||||||
|
new RegExp(`-${baseCurrency}$`),
|
||||||
|
baseCurrency
|
||||||
|
);
|
||||||
|
return symbol.replace('=X', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a symbol to a Yahoo Finance symbol
|
||||||
|
*
|
||||||
|
* Currency: USDCHF -> USDCHF=X
|
||||||
|
* Cryptocurrency: BTCUSD -> BTC-USD
|
||||||
|
* DOGEUSD -> DOGE-USD
|
||||||
|
*/
|
||||||
|
public convertToYahooFinanceSymbol(aSymbol: string) {
|
||||||
|
if (aSymbol.includes(baseCurrency) && aSymbol.length >= 6) {
|
||||||
|
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
||||||
|
return `${aSymbol}=X`;
|
||||||
|
} else if (
|
||||||
|
this.cryptocurrencyService.isCryptocurrency(
|
||||||
|
aSymbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Add a dash before the last three characters
|
||||||
|
// BTCUSD -> BTC-USD
|
||||||
|
// DOGEUSD -> DOGE-USD
|
||||||
|
// SOL1USD -> SOL1-USD
|
||||||
|
return aSymbol.replace(
|
||||||
|
new RegExp(`-?${baseCurrency}$`),
|
||||||
|
`-${baseCurrency}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return aSymbol;
|
||||||
|
}
|
||||||
|
|
||||||
public async get(
|
public async get(
|
||||||
aSymbols: string[]
|
aSymbols: string[]
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
@ -65,11 +103,11 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
currency: value.price?.currency,
|
currency: value.price?.currency,
|
||||||
dataSource: DataSource.YAHOO,
|
dataSource: this.getName(),
|
||||||
exchange: this.parseExchange(value.price?.exchangeName),
|
exchange: this.parseExchange(value.price?.exchangeName),
|
||||||
marketState:
|
marketState:
|
||||||
value.price?.marketState === 'REGULAR' ||
|
value.price?.marketState === 'REGULAR' ||
|
||||||
this.cryptocurrencyService.isCrypto(symbol)
|
this.cryptocurrencyService.isCryptocurrency(symbol)
|
||||||
? MarketState.open
|
? MarketState.open
|
||||||
: MarketState.closed,
|
: MarketState.closed,
|
||||||
marketPrice: value.price?.regularMarketPrice || 0,
|
marketPrice: value.price?.regularMarketPrice || 0,
|
||||||
@ -135,6 +173,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSameDay(from, to)) {
|
||||||
|
to = addDays(to, 1);
|
||||||
|
}
|
||||||
|
|
||||||
const yahooFinanceSymbols = aSymbols.map((symbol) => {
|
const yahooFinanceSymbols = aSymbols.map((symbol) => {
|
||||||
return this.convertToYahooFinanceSymbol(symbol);
|
return this.convertToYahooFinanceSymbol(symbol);
|
||||||
});
|
});
|
||||||
@ -179,12 +221,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return DataSource.YAHOO;
|
return DataSource.YAHOO;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
const items: LookupItem[] = [];
|
const items: LookupItem[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const get = bent(
|
const get = bent(
|
||||||
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
`${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
|
||||||
|
aQuery
|
||||||
|
)}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
||||||
'GET',
|
'GET',
|
||||||
'json',
|
'json',
|
||||||
200
|
200
|
||||||
@ -200,8 +244,8 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
.filter(({ quoteType, symbol }) => {
|
.filter(({ quoteType, symbol }) => {
|
||||||
return (
|
return (
|
||||||
(quoteType === 'CRYPTOCURRENCY' &&
|
(quoteType === 'CRYPTOCURRENCY' &&
|
||||||
this.cryptocurrencyService.isCrypto(
|
this.cryptocurrencyService.isCryptocurrency(
|
||||||
symbol.replace(new RegExp('-USD$'), 'USD').replace('1', '')
|
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
|
||||||
)) ||
|
)) ||
|
||||||
quoteType === 'EQUITY' ||
|
quoteType === 'EQUITY' ||
|
||||||
quoteType === 'ETF'
|
quoteType === 'ETF'
|
||||||
@ -209,9 +253,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
})
|
})
|
||||||
.filter(({ quoteType, symbol }) => {
|
.filter(({ quoteType, symbol }) => {
|
||||||
if (quoteType === 'CRYPTOCURRENCY') {
|
if (quoteType === 'CRYPTOCURRENCY') {
|
||||||
// Only allow cryptocurrencies in USD to avoid having redundancy in the database.
|
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
|
||||||
// Trades need to be converted manually before to USD (or a UI converter needs to be developed)
|
// Transactions need to be converted manually to the base currency before
|
||||||
return symbol.includes('USD');
|
return symbol.includes(baseCurrency);
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@ -226,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
items.push({
|
items.push({
|
||||||
symbol,
|
symbol,
|
||||||
currency: value.currency,
|
currency: value.currency,
|
||||||
dataSource: DataSource.YAHOO,
|
dataSource: this.getName(),
|
||||||
name: value.name
|
name: value.name
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -235,44 +279,6 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return { items };
|
return { items };
|
||||||
}
|
}
|
||||||
|
|
||||||
private convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
|
||||||
const symbol = aYahooFinanceSymbol.replace('-', '');
|
|
||||||
return symbol.replace('=X', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts a symbol to a Yahoo Finance symbol
|
|
||||||
*
|
|
||||||
* Currency: USDCHF -> USDCHF=X
|
|
||||||
* Cryptocurrency: BTCUSD -> BTC-USD
|
|
||||||
* DOGEUSD -> DOGE-USD
|
|
||||||
* SOL1USD -> SOL1-USD
|
|
||||||
*/
|
|
||||||
private convertToYahooFinanceSymbol(aSymbol: string) {
|
|
||||||
if (
|
|
||||||
(aSymbol.includes('CHF') ||
|
|
||||||
aSymbol.includes('EUR') ||
|
|
||||||
aSymbol.includes('USD')) &&
|
|
||||||
aSymbol.length >= 6
|
|
||||||
) {
|
|
||||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
|
||||||
return `${aSymbol}=X`;
|
|
||||||
} else if (
|
|
||||||
this.cryptocurrencyService.isCrypto(
|
|
||||||
aSymbol.replace(new RegExp('-USD$'), 'USD').replace('1', '')
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
// Add a dash before the last three characters
|
|
||||||
// BTCUSD -> BTC-USD
|
|
||||||
// DOGEUSD -> DOGE-USD
|
|
||||||
// SOL1USD -> SOL1-USD
|
|
||||||
return aSymbol.replace(new RegExp('-?USD$'), '-USD');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return aSymbol;
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseAssetClass(aPrice: IYahooFinancePrice): {
|
private parseAssetClass(aPrice: IYahooFinancePrice): {
|
||||||
assetClass: AssetClass;
|
assetClass: AssetClass;
|
||||||
assetSubClass: AssetSubClass;
|
assetSubClass: AssetSubClass;
|
||||||
|
@ -157,7 +157,12 @@ export class ExchangeRateDataService {
|
|||||||
await this.prismaService.account.findMany({
|
await this.prismaService.account.findMany({
|
||||||
distinct: ['currency'],
|
distinct: ['currency'],
|
||||||
orderBy: [{ currency: 'asc' }],
|
orderBy: [{ currency: 'asc' }],
|
||||||
select: { currency: true }
|
select: { currency: true },
|
||||||
|
where: {
|
||||||
|
currency: {
|
||||||
|
not: null
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
).forEach((account) => {
|
).forEach((account) => {
|
||||||
currencies.push(account.currency);
|
currencies.push(account.currency);
|
||||||
@ -167,7 +172,12 @@ export class ExchangeRateDataService {
|
|||||||
await this.prismaService.settings.findMany({
|
await this.prismaService.settings.findMany({
|
||||||
distinct: ['currency'],
|
distinct: ['currency'],
|
||||||
orderBy: [{ currency: 'asc' }],
|
orderBy: [{ currency: 'asc' }],
|
||||||
select: { currency: true }
|
select: { currency: true },
|
||||||
|
where: {
|
||||||
|
currency: {
|
||||||
|
not: null
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
).forEach((userSettings) => {
|
).forEach((userSettings) => {
|
||||||
currencies.push(userSettings.currency);
|
currencies.push(userSettings.currency);
|
||||||
@ -177,7 +187,12 @@ export class ExchangeRateDataService {
|
|||||||
await this.prismaService.symbolProfile.findMany({
|
await this.prismaService.symbolProfile.findMany({
|
||||||
distinct: ['currency'],
|
distinct: ['currency'],
|
||||||
orderBy: [{ currency: 'asc' }],
|
orderBy: [{ currency: 'asc' }],
|
||||||
select: { currency: true }
|
select: { currency: true },
|
||||||
|
where: {
|
||||||
|
currency: {
|
||||||
|
not: null
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
).forEach((symbolProfile) => {
|
).forEach((symbolProfile) => {
|
||||||
currencies.push(symbolProfile.currency);
|
currencies.push(symbolProfile.currency);
|
||||||
|
@ -4,6 +4,7 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
ACCESS_TOKEN_SALT: string;
|
ACCESS_TOKEN_SALT: string;
|
||||||
ALPHA_VANTAGE_API_KEY: string;
|
ALPHA_VANTAGE_API_KEY: string;
|
||||||
CACHE_TTL: number;
|
CACHE_TTL: number;
|
||||||
|
DATA_SOURCE_PRIMARY: string;
|
||||||
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
||||||
ENABLE_FEATURE_BLOG: boolean;
|
ENABLE_FEATURE_BLOG: boolean;
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||||
@ -16,8 +17,12 @@ export interface Environment extends CleanedEnvAccessors {
|
|||||||
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
|
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
|
||||||
GOOGLE_CLIENT_ID: string;
|
GOOGLE_CLIENT_ID: string;
|
||||||
GOOGLE_SECRET: string;
|
GOOGLE_SECRET: string;
|
||||||
|
GOOGLE_SHEETS_ACCOUNT: string;
|
||||||
|
GOOGLE_SHEETS_ID: string;
|
||||||
|
GOOGLE_SHEETS_PRIVATE_KEY: string;
|
||||||
JWT_SECRET_KEY: string;
|
JWT_SECRET_KEY: string;
|
||||||
MAX_ITEM_IN_CACHE: number;
|
MAX_ITEM_IN_CACHE: number;
|
||||||
|
MAX_ORDERS_TO_IMPORT: number;
|
||||||
PORT: number;
|
PORT: number;
|
||||||
RAKUTEN_RAPID_API_KEY: string;
|
RAKUTEN_RAPID_API_KEY: string;
|
||||||
REDIS_HOST: string;
|
REDIS_HOST: string;
|
||||||
|
@ -3,11 +3,10 @@ import {
|
|||||||
AssetClass,
|
AssetClass,
|
||||||
AssetSubClass,
|
AssetSubClass,
|
||||||
DataSource,
|
DataSource,
|
||||||
SymbolProfile
|
SymbolProfile,
|
||||||
|
Type as TypeOfOrder
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
|
|
||||||
import { OrderType } from '../../models/order-type';
|
|
||||||
|
|
||||||
export const MarketState = {
|
export const MarketState = {
|
||||||
closed: 'closed',
|
closed: 'closed',
|
||||||
delayed: 'delayed',
|
delayed: 'delayed',
|
||||||
@ -24,7 +23,7 @@ export interface IOrder {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
symbolProfile: SymbolProfile;
|
symbolProfile: SymbolProfile;
|
||||||
type: OrderType;
|
type: TypeOfOrder;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
|
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||||
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
|
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MarketData, Prisma } from '@prisma/client';
|
import { DataSource, MarketData, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MarketDataService {
|
export class MarketDataService {
|
||||||
@ -65,4 +66,22 @@ export class MarketDataService {
|
|||||||
where
|
where
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async updateMarketData(params: {
|
||||||
|
data: { dataSource: DataSource } & UpdateMarketDataDto;
|
||||||
|
where: Prisma.MarketDataWhereUniqueInput;
|
||||||
|
}): Promise<MarketData> {
|
||||||
|
const { data, where } = params;
|
||||||
|
|
||||||
|
return this.prismaService.marketData.upsert({
|
||||||
|
where,
|
||||||
|
create: {
|
||||||
|
dataSource: data.dataSource,
|
||||||
|
date: where.date_symbol.date,
|
||||||
|
marketPrice: data.marketPrice,
|
||||||
|
symbol: where.date_symbol.symbol
|
||||||
|
},
|
||||||
|
update: { marketPrice: data.marketPrice }
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,13 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
|
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'about/changelog',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/about/changelog/changelog-page.module').then(
|
||||||
|
(m) => m.ChangelogPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'account',
|
path: 'account',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -33,6 +40,11 @@ const routes: Routes = [
|
|||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
|
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'blog',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'de/blog/2021/07/hallo-ghostfolio',
|
path: 'de/blog/2021/07/hallo-ghostfolio',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -47,6 +59,13 @@ const routes: Routes = [
|
|||||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
||||||
).then((m) => m.HelloGhostfolioPageModule)
|
).then((m) => m.HelloGhostfolioPageModule)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
|
||||||
|
loadChildren: () =>
|
||||||
|
import(
|
||||||
|
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
||||||
|
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'home',
|
path: 'home',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -66,6 +85,13 @@ const routes: Routes = [
|
|||||||
(m) => m.PortfolioPageModule
|
(m) => m.PortfolioPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'portfolio/activities',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/portfolio/transactions/transactions-page.module').then(
|
||||||
|
(m) => m.TransactionsPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'portfolio/allocations',
|
path: 'portfolio/allocations',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
@ -87,13 +113,6 @@ const routes: Routes = [
|
|||||||
(m) => m.ReportPageModule
|
(m) => m.ReportPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'portfolio/transactions',
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/portfolio/transactions/transactions-page.module').then(
|
|
||||||
(m) => m.TransactionsPageModule
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'pricing',
|
path: 'pricing',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
|
@ -89,7 +89,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.tokenStorageService.signOut();
|
this.tokenStorageService.signOut();
|
||||||
this.userService.remove();
|
this.userService.remove();
|
||||||
|
|
||||||
window.location.reload();
|
this.router.navigate(['/']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
|
@ -5,10 +5,10 @@ import { MatInputModule } from '@angular/material/input';
|
|||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
|
||||||
import { AccountsTableComponent } from './accounts-table.component';
|
import { AccountsTableComponent } from './accounts-table.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -1,24 +1,35 @@
|
|||||||
<div class="py-2">
|
<div>
|
||||||
|
<gf-line-chart
|
||||||
|
class="mb-4"
|
||||||
|
[historicalDataItems]="historicalDataItems"
|
||||||
|
[showXAxis]="true"
|
||||||
|
[showYAxis]="true"
|
||||||
|
></gf-line-chart>
|
||||||
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
|
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
|
||||||
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
||||||
<div class="align-items-center d-flex flex-grow-1 px-1">
|
<div class="align-items-center d-flex flex-grow-1 px-1">
|
||||||
<div
|
<div
|
||||||
*ngFor="let dayItem of days; let i = index"
|
*ngFor="let dayItem of days; let i = index"
|
||||||
class="day"
|
class="day"
|
||||||
|
[ngClass]="{
|
||||||
|
'cursor-pointer valid': isDateOfInterest(
|
||||||
|
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
||||||
|
),
|
||||||
|
available:
|
||||||
|
marketDataByMonth[itemByMonth.key][
|
||||||
|
i + 1 < 10 ? '0' + (i + 1) : i + 1
|
||||||
|
]?.day ===
|
||||||
|
i + 1
|
||||||
|
}"
|
||||||
[title]="
|
[title]="
|
||||||
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
||||||
| date: defaultDateFormat) ?? ''
|
| date: defaultDateFormat) ?? ''
|
||||||
"
|
"
|
||||||
[ngClass]="{
|
|
||||||
valid: isDateOfInterest(
|
|
||||||
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
|
|
||||||
),
|
|
||||||
'available cursor-pointer':
|
|
||||||
marketDataByMonth[itemByMonth.key][i + 1]?.day === i + 1
|
|
||||||
}"
|
|
||||||
(click)="
|
(click)="
|
||||||
marketDataByMonth[itemByMonth.key][i + 1] &&
|
onOpenMarketDataDetail({
|
||||||
onOpenMarketDataDetail(marketDataByMonth[itemByMonth.key][i + 1])
|
day: i + 1 < 10 ? '0' + (i + 1) : i + 1,
|
||||||
|
yearMonth: itemByMonth.key
|
||||||
|
})
|
||||||
"
|
"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -14,6 +14,10 @@
|
|||||||
margin-right: 0.25rem;
|
margin-right: 0.25rem;
|
||||||
width: 0.5rem;
|
width: 0.5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
&.valid {
|
&.valid {
|
||||||
background-color: var(--danger);
|
background-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
OnInit
|
OnInit,
|
||||||
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { MarketData } from '@prisma/client';
|
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||||
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
import { format, isBefore, isValid, parse } from 'date-fns';
|
import { format, isBefore, isValid, parse } from 'date-fns';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject, takeUntil } from 'rxjs';
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
@ -22,11 +25,16 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
|
|||||||
templateUrl: './admin-market-data-detail.component.html'
|
templateUrl: './admin-market-data-detail.component.html'
|
||||||
})
|
})
|
||||||
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||||
|
@Input() dataSource: DataSource;
|
||||||
@Input() marketData: MarketData[];
|
@Input() marketData: MarketData[];
|
||||||
|
@Input() symbol: string;
|
||||||
|
|
||||||
|
@Output() marketDataChanged = new EventEmitter<boolean>();
|
||||||
|
|
||||||
public days = Array(31);
|
public days = Array(31);
|
||||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
|
public historicalDataItems: LineChartItem[];
|
||||||
public marketDataByMonth: {
|
public marketDataByMonth: {
|
||||||
[yearMonth: string]: { [day: string]: MarketData & { day: number } };
|
[yearMonth: string]: { [day: string]: MarketData & { day: number } };
|
||||||
} = {};
|
} = {};
|
||||||
@ -43,6 +51,12 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
|
this.historicalDataItems = this.marketData.map((marketDataItem) => {
|
||||||
|
return {
|
||||||
|
date: format(marketDataItem.date, DATE_FORMAT),
|
||||||
|
value: marketDataItem.marketPrice
|
||||||
|
};
|
||||||
|
});
|
||||||
this.marketDataByMonth = {};
|
this.marketDataByMonth = {};
|
||||||
|
|
||||||
for (const marketDataItem of this.marketData) {
|
for (const marketDataItem of this.marketData) {
|
||||||
@ -53,7 +67,9 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
this.marketDataByMonth[key] = {};
|
this.marketDataByMonth[key] = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
this.marketDataByMonth[key][currentDay] = {
|
this.marketDataByMonth[key][
|
||||||
|
currentDay < 10 ? `0${currentDay}` : currentDay
|
||||||
|
] = {
|
||||||
...marketDataItem,
|
...marketDataItem,
|
||||||
day: currentDay
|
day: currentDay
|
||||||
};
|
};
|
||||||
@ -66,12 +82,21 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
return isValid(date) && isBefore(date, new Date());
|
return isValid(date) && isBefore(date, new Date());
|
||||||
}
|
}
|
||||||
|
|
||||||
public onOpenMarketDataDetail({ date, marketPrice, symbol }: MarketData) {
|
public onOpenMarketDataDetail({
|
||||||
|
day,
|
||||||
|
yearMonth
|
||||||
|
}: {
|
||||||
|
day: string;
|
||||||
|
yearMonth: string;
|
||||||
|
}) {
|
||||||
|
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
|
||||||
|
|
||||||
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
||||||
data: {
|
data: {
|
||||||
marketPrice,
|
marketPrice,
|
||||||
symbol,
|
dataSource: this.dataSource,
|
||||||
date: format(date, DEFAULT_DATE_FORMAT)
|
date: new Date(`${yearMonth}-${day}`),
|
||||||
|
symbol: this.symbol
|
||||||
},
|
},
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
@ -80,7 +105,9 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
|||||||
dialogRef
|
dialogRef
|
||||||
.afterClosed()
|
.afterClosed()
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(() => {});
|
.subscribe(({ withRefresh }) => {
|
||||||
|
this.marketDataChanged.next(withRefresh);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||||
|
|
||||||
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
|
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
|
||||||
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
|
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
|
||||||
@ -7,7 +8,7 @@ import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/mark
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AdminMarketDataDetailComponent],
|
declarations: [AdminMarketDataDetailComponent],
|
||||||
exports: [AdminMarketDataDetailComponent],
|
exports: [AdminMarketDataDetailComponent],
|
||||||
imports: [CommonModule, GfMarketDataDetailDialogModule],
|
imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule],
|
||||||
providers: [],
|
providers: [],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface MarketDataDetailDialogParams {
|
export interface MarketDataDetailDialogParams {
|
||||||
date: string;
|
dataSource: DataSource;
|
||||||
|
date: Date;
|
||||||
marketPrice: number;
|
marketPrice: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
Inject,
|
Inject,
|
||||||
OnDestroy
|
OnDestroy
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { Subject } from 'rxjs';
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
import { MarketDataDetailDialogParams } from './interfaces/interfaces';
|
import { MarketDataDetailDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
@ -20,6 +22,8 @@ export class MarketDataDetailDialog implements OnDestroy {
|
|||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
public dialogRef: MatDialogRef<MarketDataDetailDialog>,
|
public dialogRef: MatDialogRef<MarketDataDetailDialog>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams
|
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams
|
||||||
) {}
|
) {}
|
||||||
@ -27,7 +31,36 @@ export class MarketDataDetailDialog implements OnDestroy {
|
|||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
public onCancel(): void {
|
public onCancel(): void {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close({ withRefresh: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
public onFetchSymbolForDate() {
|
||||||
|
this.adminService
|
||||||
|
.fetchSymbolForDate({
|
||||||
|
dataSource: this.data.dataSource,
|
||||||
|
date: this.data.date,
|
||||||
|
symbol: this.data.symbol
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ marketPrice }) => {
|
||||||
|
this.data.marketPrice = marketPrice;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onUpdate() {
|
||||||
|
this.adminService
|
||||||
|
.putMarketData({
|
||||||
|
dataSource: this.data.dataSource,
|
||||||
|
date: this.data.date,
|
||||||
|
marketData: { marketPrice: this.data.marketPrice },
|
||||||
|
symbol: this.data.symbol
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.dialogRef.close({ withRefresh: true });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
|
@ -1,25 +1,50 @@
|
|||||||
<form class="d-flex flex-column h-100">
|
<form class="d-flex flex-column h-100">
|
||||||
<h1 mat-dialog-title i18n>Details for {{ data.symbol }}</h1>
|
<h1 mat-dialog-title i18n>Details for {{ data.symbol }}</h1>
|
||||||
<div class="flex-grow-1" mat-dialog-content>
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
<div>
|
<div class="mb-3">
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>Date</mat-label>
|
<mat-label i18n>Date</mat-label>
|
||||||
<input matInput name="date" readonly [(ngModel)]="data.date" />
|
<input
|
||||||
|
disabled
|
||||||
|
matInput
|
||||||
|
name="date"
|
||||||
|
[matDatepicker]="date"
|
||||||
|
[(ngModel)]="data.date"
|
||||||
|
/>
|
||||||
|
<mat-datepicker-toggle matSuffix [for]="date">
|
||||||
|
<ion-icon
|
||||||
|
class="text-muted"
|
||||||
|
matDatepickerToggleIcon
|
||||||
|
name="calendar-clear-outline"
|
||||||
|
></ion-icon>
|
||||||
|
</mat-datepicker-toggle>
|
||||||
|
<mat-datepicker #date disabled="true"></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<mat-label i18n>MarketPrice</mat-label>
|
<mat-label i18n>Market Price</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
name="marketPrice"
|
name="marketPrice"
|
||||||
readonly
|
type="number"
|
||||||
[(ngModel)]="data.marketPrice"
|
[(ngModel)]="data.marketPrice"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
mat-icon-button
|
||||||
|
matSuffix
|
||||||
|
title="Fetch market price"
|
||||||
|
(click)="onFetchSymbolForDate()"
|
||||||
|
>
|
||||||
|
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="justify-content-end" mat-dialog-actions>
|
<div class="justify-content-end" mat-dialog-actions>
|
||||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||||
|
<button color="primary" i18n mat-flat-button (click)="onUpdate()">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
@ -15,6 +16,7 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
|
|||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
MatDatepickerModule,
|
||||||
MatDialogModule,
|
MatDialogModule,
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
|
@ -3,5 +3,21 @@
|
|||||||
|
|
||||||
.mat-dialog-content {
|
.mat-dialog-content {
|
||||||
max-height: unset;
|
max-height: unset;
|
||||||
|
|
||||||
|
.mat-form-field-appearance-outline {
|
||||||
|
::ng-deep {
|
||||||
|
.mat-form-field-suffix {
|
||||||
|
top: -0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mat-form-field-wrapper {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 130%;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -43,6 +43,19 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
this.fetchAdminMarketData();
|
this.fetchAdminMarketData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onGatherProfileDataBySymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
this.adminService
|
||||||
|
.gatherProfileDataBySymbol({ dataSource, symbol })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
public onGatherSymbol({
|
public onGatherSymbol({
|
||||||
dataSource,
|
dataSource,
|
||||||
symbol
|
symbol
|
||||||
@ -68,6 +81,13 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||||
|
if (withRefresh) {
|
||||||
|
this.fetchAdminMarketData();
|
||||||
|
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
|
@ -4,7 +4,6 @@
|
|||||||
<table class="gf-table w-100">
|
<table class="gf-table w-100">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="mat-header-row">
|
<tr class="mat-header-row">
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>First Transaction</th>
|
<th class="mat-header-cell px-1 py-2" i18n>First Transaction</th>
|
||||||
@ -17,7 +16,6 @@
|
|||||||
class="cursor-pointer mat-row"
|
class="cursor-pointer mat-row"
|
||||||
(click)="setCurrentSymbol(item.symbol)"
|
(click)="setCurrentSymbol(item.symbol)"
|
||||||
>
|
>
|
||||||
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
|
|
||||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
||||||
<td class="mat-cell px-1 py-2">{{ item.dataSource}}</td>
|
<td class="mat-cell px-1 py-2">{{ item.dataSource}}</td>
|
||||||
<td class="mat-cell px-1 py-2">
|
<td class="mat-cell px-1 py-2">
|
||||||
@ -40,14 +38,23 @@
|
|||||||
>
|
>
|
||||||
Gather Data
|
Gather Data
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
i18n
|
||||||
|
mat-menu-item
|
||||||
|
(click)="onGatherProfileDataBySymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
||||||
|
>
|
||||||
|
Gather Profile Data
|
||||||
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
|
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
|
||||||
<td></td>
|
<td class="p-1" colspan="4">
|
||||||
<td colspan="4">
|
|
||||||
<gf-admin-market-data-detail
|
<gf-admin-market-data-detail
|
||||||
|
[dataSource]="item.dataSource"
|
||||||
[marketData]="marketDataDetails"
|
[marketData]="marketDataDetails"
|
||||||
|
[symbol]="item.symbol"
|
||||||
|
(marketDataChanged)="onMarketDataChanged($event)"
|
||||||
></gf-admin-market-data-detail>
|
></gf-admin-market-data-detail>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -6,11 +6,12 @@ import { DataService } from '@ghostfolio/client/services/data.service';
|
|||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
import {
|
import {
|
||||||
DEFAULT_DATE_FORMAT,
|
DEFAULT_DATE_FORMAT,
|
||||||
|
PROPERTY_COUPONS,
|
||||||
PROPERTY_CURRENCIES,
|
PROPERTY_CURRENCIES,
|
||||||
PROPERTY_IS_READ_ONLY_MODE,
|
PROPERTY_IS_READ_ONLY_MODE,
|
||||||
PROPERTY_SYSTEM_MESSAGE
|
PROPERTY_SYSTEM_MESSAGE
|
||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
import { Coupon, InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import {
|
import {
|
||||||
differenceInSeconds,
|
differenceInSeconds,
|
||||||
@ -28,11 +29,13 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
templateUrl: './admin-overview.html'
|
templateUrl: './admin-overview.html'
|
||||||
})
|
})
|
||||||
export class AdminOverviewComponent implements OnDestroy, OnInit {
|
export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||||
|
public coupons: Coupon[];
|
||||||
public customCurrencies: string[];
|
public customCurrencies: string[];
|
||||||
public dataGatheringInProgress: boolean;
|
public dataGatheringInProgress: boolean;
|
||||||
public dataGatheringProgress: number;
|
public dataGatheringProgress: number;
|
||||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||||
public exchangeRates: { label1: string; label2: string; value: number }[];
|
public exchangeRates: { label1: string; label2: string; value: number }[];
|
||||||
|
public hasPermissionForSubscription: boolean;
|
||||||
public hasPermissionForSystemMessage: boolean;
|
public hasPermissionForSystemMessage: boolean;
|
||||||
public hasPermissionToToggleReadOnlyMode: boolean;
|
public hasPermissionToToggleReadOnlyMode: boolean;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
@ -61,6 +64,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionForSubscription = hasPermission(
|
||||||
|
this.info.globalPermissions,
|
||||||
|
permissions.enableSubscription
|
||||||
|
);
|
||||||
|
|
||||||
this.hasPermissionForSystemMessage = hasPermission(
|
this.hasPermissionForSystemMessage = hasPermission(
|
||||||
this.info.globalPermissions,
|
this.info.globalPermissions,
|
||||||
permissions.enableSystemMessage
|
permissions.enableSystemMessage
|
||||||
@ -96,6 +104,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onAddCoupon() {
|
||||||
|
const coupons = [...this.coupons, { code: this.generateCouponCode(16) }];
|
||||||
|
this.putCoupons(coupons);
|
||||||
|
}
|
||||||
|
|
||||||
public onAddCurrency() {
|
public onAddCurrency() {
|
||||||
const currency = prompt('Please add a currency:');
|
const currency = prompt('Please add a currency:');
|
||||||
|
|
||||||
@ -105,6 +118,17 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onDeleteCoupon(aCouponCode: string) {
|
||||||
|
const confirmation = confirm('Do you really want to delete this coupon?');
|
||||||
|
|
||||||
|
if (confirmation) {
|
||||||
|
const coupons = this.coupons.filter((coupon) => {
|
||||||
|
return coupon.code !== aCouponCode;
|
||||||
|
});
|
||||||
|
this.putCoupons(coupons);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public onDeleteCurrency(aCurrency: string) {
|
public onDeleteCurrency(aCurrency: string) {
|
||||||
const confirmation = confirm('Do you really want to delete this currency?');
|
const confirmation = confirm('Do you really want to delete this currency?');
|
||||||
|
|
||||||
@ -185,6 +209,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
transactionCount,
|
transactionCount,
|
||||||
userCount
|
userCount
|
||||||
}) => {
|
}) => {
|
||||||
|
this.coupons = (settings[PROPERTY_COUPONS] as Coupon[]) ?? [];
|
||||||
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
|
||||||
this.dataGatheringProgress = dataGatheringProgress;
|
this.dataGatheringProgress = dataGatheringProgress;
|
||||||
this.exchangeRates = exchangeRates;
|
this.exchangeRates = exchangeRates;
|
||||||
@ -210,6 +235,32 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private generateCouponCode(aLength: number) {
|
||||||
|
const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ123456789';
|
||||||
|
let couponCode = '';
|
||||||
|
|
||||||
|
for (let i = 0; i < aLength; i++) {
|
||||||
|
couponCode += characters.charAt(
|
||||||
|
Math.floor(Math.random() * characters.length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return couponCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
private putCoupons(aCoupons: Coupon[]) {
|
||||||
|
this.dataService
|
||||||
|
.putAdminSetting(PROPERTY_COUPONS, {
|
||||||
|
value: JSON.stringify(aCoupons)
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private putCurrencies(aCurrencies: string[]) {
|
private putCurrencies(aCurrencies: string[]) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.putAdminSetting(PROPERTY_CURRENCIES, {
|
.putAdminSetting(PROPERTY_CURRENCIES, {
|
||||||
|
@ -142,7 +142,7 @@
|
|||||||
class="mr-1"
|
class="mr-1"
|
||||||
name="information-circle-outline"
|
name="information-circle-outline"
|
||||||
></ion-icon>
|
></ion-icon>
|
||||||
<span i18n>Set System Message</span>
|
<span i18n>Set Message</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -156,6 +156,27 @@
|
|||||||
></mat-slide-toggle>
|
></mat-slide-toggle>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div *ngIf="hasPermissionForSubscription" class="d-flex my-3">
|
||||||
|
<div class="w-50" i18n>Coupons</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<div *ngFor="let coupon of coupons">
|
||||||
|
<span>{{ coupon.code }}</span>
|
||||||
|
<button
|
||||||
|
class="mini-icon mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
(click)="onDeleteCoupon(coupon.code)"
|
||||||
|
>
|
||||||
|
<ion-icon name="trash-outline"></ion-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<button color="primary" mat-flat-button (click)="onAddCoupon()">
|
||||||
|
<ion-icon class="mr-1" name="add-outline"></ion-icon>
|
||||||
|
<span i18n>Add Coupon</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</mat-card-content>
|
</mat-card-content>
|
||||||
</mat-card>
|
</mat-card>
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,7 +5,7 @@
|
|||||||
<table class="gf-table">
|
<table class="gf-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="mat-header-row">
|
<tr class="mat-header-row">
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
Registration
|
Registration
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
<button
|
<button
|
||||||
*ngIf="deviceType === 'mobile'"
|
*ngIf="deviceType === 'mobile'"
|
||||||
|
class="mt-2"
|
||||||
mat-button
|
mat-button
|
||||||
(click)="onClickCloseButton()"
|
(click)="onClickCloseButton()"
|
||||||
>
|
>
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
:host {
|
:host {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin-bottom: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import {
|
import {
|
||||||
RANGE,
|
RANGE,
|
||||||
SettingsStorageService
|
SettingsStorageService
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
||||||
import { Position, User } from '@ghostfolio/common/interfaces';
|
import { Position, User } from '@ghostfolio/common/interfaces';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { DateRange } from '@ghostfolio/common/types';
|
import { DateRange } from '@ghostfolio/common/types';
|
||||||
@ -19,6 +23,7 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
})
|
})
|
||||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||||
public dateRange: DateRange;
|
public dateRange: DateRange;
|
||||||
|
public dateRangeOptions = defaultDateRangeOptions;
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
public hasPermissionToCreateOrder: boolean;
|
public hasPermissionToCreateOrder: boolean;
|
||||||
public positions: Position[];
|
public positions: Position[];
|
||||||
@ -33,9 +38,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
private changeDetectorRef: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
|
private dialog: MatDialog,
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private router: Router,
|
||||||
private settingsStorageService: SettingsStorageService,
|
private settingsStorageService: SettingsStorageService,
|
||||||
private userService: UserService
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
|
route.queryParams
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((params) => {
|
||||||
|
if (params['positionDetailDialog'] && params['symbol']) {
|
||||||
|
this.openPositionDialog({ symbol: params['symbol'] });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.userService.stateChanged
|
this.userService.stateChanged
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((state) => {
|
.subscribe((state) => {
|
||||||
@ -64,12 +80,48 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
|||||||
this.update();
|
this.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onChangeDateRange(aDateRange: DateRange) {
|
||||||
|
this.dateRange = aDateRange;
|
||||||
|
this.settingsStorageService.setSetting(RANGE, this.dateRange);
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private openPositionDialog({ symbol }: { symbol: string }) {
|
||||||
|
this.userService
|
||||||
|
.get()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((user) => {
|
||||||
|
this.user = user;
|
||||||
|
|
||||||
|
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||||
|
autoFocus: false,
|
||||||
|
data: {
|
||||||
|
symbol,
|
||||||
|
baseCurrency: this.user?.settings?.baseCurrency,
|
||||||
|
deviceType: this.deviceType,
|
||||||
|
locale: this.user?.settings?.locale
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.router.navigate(['.'], { relativeTo: this.route });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private update() {
|
private update() {
|
||||||
|
this.positions = undefined;
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchPositions({ range: this.dateRange })
|
.fetchPositions({ range: this.dateRange })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
@ -1,4 +1,12 @@
|
|||||||
<div class="container justify-content-center pb-3 px-3">
|
<div class="container justify-content-center p-3">
|
||||||
|
<div class="mb-3 text-center">
|
||||||
|
<gf-toggle
|
||||||
|
[defaultValue]="dateRange"
|
||||||
|
[isLoading]="positions === undefined"
|
||||||
|
[options]="dateRangeOptions"
|
||||||
|
(change)="onChangeDateRange($event.value)"
|
||||||
|
></gf-toggle>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
|
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
|
||||||
<mat-card class="p-0">
|
<mat-card class="p-0">
|
||||||
@ -6,6 +14,7 @@
|
|||||||
<gf-positions
|
<gf-positions
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
[deviceType]="deviceType"
|
[deviceType]="deviceType"
|
||||||
|
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||||
[locale]="user?.settings?.locale"
|
[locale]="user?.settings?.locale"
|
||||||
[positions]="positions"
|
[positions]="positions"
|
||||||
[range]="dateRange"
|
[range]="dateRange"
|
||||||
@ -17,8 +26,8 @@
|
|||||||
class="mt-3"
|
class="mt-3"
|
||||||
i18n
|
i18n
|
||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/portfolio', 'transactions']"
|
[routerLink]="['/portfolio', 'activities']"
|
||||||
>Manage Transactions...</a
|
>Manage Activities...</a
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,7 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
|
||||||
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
|
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
|
||||||
|
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||||
|
|
||||||
import { HomeHoldingsComponent } from './home-holdings.component';
|
import { HomeHoldingsComponent } from './home-holdings.component';
|
||||||
|
|
||||||
@ -12,7 +14,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
|
|||||||
exports: [],
|
exports: [],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfPositionDetailDialogModule,
|
||||||
GfPositionsModule,
|
GfPositionsModule,
|
||||||
|
GfToggleModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
<div class="no-gutters row w-100">
|
<div class="no-gutters row w-100">
|
||||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
<div class="mb-2 text-center text-muted">
|
<div class="mb-2 text-center text-muted">
|
||||||
<small i18n>Last 10 Days</small>
|
<small i18n>Last 30 Days</small>
|
||||||
</div>
|
</div>
|
||||||
<gf-line-chart
|
<gf-line-chart
|
||||||
class="mb-5"
|
class="mb-5"
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
|
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
import {
|
import {
|
||||||
@ -7,7 +6,9 @@ import {
|
|||||||
SettingsStorageService
|
SettingsStorageService
|
||||||
} from '@ghostfolio/client/services/settings-storage.service';
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
||||||
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
|
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { DateRange } from '@ghostfolio/common/types';
|
import { DateRange } from '@ghostfolio/common/types';
|
||||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
@ -21,15 +22,11 @@ import { takeUntil } from 'rxjs/operators';
|
|||||||
})
|
})
|
||||||
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||||
public dateRange: DateRange;
|
public dateRange: DateRange;
|
||||||
public dateRangeOptions: ToggleOption[] = [
|
public dateRangeOptions = defaultDateRangeOptions;
|
||||||
{ label: 'Today', value: '1d' },
|
|
||||||
{ label: 'YTD', value: 'ytd' },
|
|
||||||
{ label: '1Y', value: '1y' },
|
|
||||||
{ label: '5Y', value: '5y' },
|
|
||||||
{ label: 'Max', value: 'max' }
|
|
||||||
];
|
|
||||||
public deviceType: string;
|
public deviceType: string;
|
||||||
|
public hasError: boolean;
|
||||||
public hasImpersonationId: boolean;
|
public hasImpersonationId: boolean;
|
||||||
|
public hasPermissionToCreateOrder: boolean;
|
||||||
public historicalDataItems: LineChartItem[];
|
public historicalDataItems: LineChartItem[];
|
||||||
public isAllTimeHigh: boolean;
|
public isAllTimeHigh: boolean;
|
||||||
public isAllTimeLow: boolean;
|
public isAllTimeLow: boolean;
|
||||||
@ -56,6 +53,11 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
if (state?.user) {
|
if (state?.user) {
|
||||||
this.user = state.user;
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionToCreateOrder = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.createOrder
|
||||||
|
);
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -116,7 +118,8 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
|||||||
.fetchPortfolioPerformance({ range: this.dateRange })
|
.fetchPortfolioPerformance({ range: this.dateRange })
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((response) => {
|
.subscribe((response) => {
|
||||||
this.performance = response;
|
this.hasError = response.hasErrors;
|
||||||
|
this.performance = response.performance;
|
||||||
this.isLoadingPerformance = false;
|
this.isLoadingPerformance = false;
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
|
@ -1,15 +1,5 @@
|
|||||||
<div
|
<div
|
||||||
class="
|
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
|
||||||
align-items-center
|
|
||||||
container
|
|
||||||
d-flex
|
|
||||||
flex-column
|
|
||||||
h-100
|
|
||||||
justify-content-center
|
|
||||||
overview
|
|
||||||
p-0
|
|
||||||
position-relative
|
|
||||||
"
|
|
||||||
>
|
>
|
||||||
<div class="row w-100">
|
<div class="row w-100">
|
||||||
<div class="chart-container col">
|
<div class="chart-container col">
|
||||||
@ -23,7 +13,7 @@
|
|||||||
[showYAxis]="false"
|
[showYAxis]="false"
|
||||||
></gf-line-chart>
|
></gf-line-chart>
|
||||||
<div
|
<div
|
||||||
*ngIf="historicalDataItems?.length === 0"
|
*ngIf="hasPermissionToCreateOrder&& historicalDataItems?.length === 0"
|
||||||
class="align-items-center d-flex h-100 justify-content-center w-100"
|
class="align-items-center d-flex h-100 justify-content-center w-100"
|
||||||
>
|
>
|
||||||
<div class="d-flex justify-content-center">
|
<div class="d-flex justify-content-center">
|
||||||
@ -37,6 +27,8 @@
|
|||||||
<gf-portfolio-performance
|
<gf-portfolio-performance
|
||||||
class="pb-4"
|
class="pb-4"
|
||||||
[baseCurrency]="user?.settings?.baseCurrency"
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
|
[deviceType]="deviceType"
|
||||||
|
[hasError]="hasError"
|
||||||
[isAllTimeHigh]="isAllTimeHigh"
|
[isAllTimeHigh]="isAllTimeHigh"
|
||||||
[isAllTimeLow]="isAllTimeLow"
|
[isAllTimeLow]="isAllTimeLow"
|
||||||
[isLoading]="isLoadingPerformance"
|
[isLoading]="isLoadingPerformance"
|
||||||
|
@ -10,6 +10,7 @@ import {
|
|||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { primaryColorRgb } from '@ghostfolio/common/config';
|
import { primaryColorRgb } from '@ghostfolio/common/config';
|
||||||
|
import { parseDate } from '@ghostfolio/common/helper';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
import {
|
import {
|
||||||
Chart,
|
Chart,
|
||||||
@ -19,7 +20,7 @@ import {
|
|||||||
PointElement,
|
PointElement,
|
||||||
TimeScale
|
TimeScale
|
||||||
} from 'chart.js';
|
} from 'chart.js';
|
||||||
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
|
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-investment-chart',
|
selector: 'gf-investment-chart',
|
||||||
@ -27,8 +28,10 @@ import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
|
|||||||
templateUrl: './investment-chart.component.html',
|
templateUrl: './investment-chart.component.html',
|
||||||
styleUrls: ['./investment-chart.component.scss']
|
styleUrls: ['./investment-chart.component.scss']
|
||||||
})
|
})
|
||||||
export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||||
|
@Input() daysInMarket: number;
|
||||||
@Input() investments: InvestmentItem[];
|
@Input() investments: InvestmentItem[];
|
||||||
|
@Input() isInPercent = false;
|
||||||
|
|
||||||
@ViewChild('chartCanvas') chartCanvas;
|
@ViewChild('chartCanvas') chartCanvas;
|
||||||
|
|
||||||
@ -45,8 +48,6 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {}
|
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
if (this.investments) {
|
if (this.investments) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
@ -61,19 +62,25 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
if (this.investments?.length > 0) {
|
if (this.investments?.length > 0) {
|
||||||
// Extend chart by three months (before)
|
// Extend chart by 5% of days in market (before)
|
||||||
const firstItem = this.investments[0];
|
const firstItem = this.investments[0];
|
||||||
this.investments.unshift({
|
this.investments.unshift({
|
||||||
...firstItem,
|
...firstItem,
|
||||||
date: subMonths(parseISO(firstItem.date), 3).toISOString(),
|
date: subDays(
|
||||||
|
parseISO(firstItem.date),
|
||||||
|
this.daysInMarket * 0.05 || 90
|
||||||
|
).toISOString(),
|
||||||
investment: 0
|
investment: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extend chart by three months (after)
|
// Extend chart by 5% of days in market (after)
|
||||||
const lastItem = this.investments[this.investments.length - 1];
|
const lastItem = this.investments[this.investments.length - 1];
|
||||||
this.investments.push({
|
this.investments.push({
|
||||||
...lastItem,
|
...lastItem,
|
||||||
date: addMonths(new Date(), 3).toISOString()
|
date: addDays(
|
||||||
|
parseDate(lastItem.date),
|
||||||
|
this.daysInMarket * 0.05 || 90
|
||||||
|
).toISOString()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,12 +143,26 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
display: false,
|
display: !this.isInPercent,
|
||||||
grid: {
|
grid: {
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
display: false
|
display: true,
|
||||||
|
callback: (tickValue, index, ticks) => {
|
||||||
|
if (index === 0 || index === ticks.length - 1) {
|
||||||
|
// Only print last and first legend entry
|
||||||
|
if (typeof tickValue === 'number') {
|
||||||
|
return tickValue.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tickValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
mirror: true,
|
||||||
|
z: 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,6 @@ import { InvestmentChartComponent } from './investment-chart.component';
|
|||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [InvestmentChartComponent],
|
declarations: [InvestmentChartComponent],
|
||||||
exports: [InvestmentChartComponent],
|
exports: [InvestmentChartComponent],
|
||||||
imports: [CommonModule, NgxSkeletonLoaderModule],
|
imports: [CommonModule, NgxSkeletonLoaderModule]
|
||||||
providers: []
|
|
||||||
})
|
})
|
||||||
export class GfInvestmentChartModule {}
|
export class GfInvestmentChartModule {}
|
||||||
|
@ -1,12 +1,18 @@
|
|||||||
<div class="container p-0">
|
<div class="container p-0">
|
||||||
<div
|
<div class="no-gutters row">
|
||||||
class="no-gutters row"
|
<div
|
||||||
[ngClass]="{
|
class="flex-grow-1 status text-muted text-right"
|
||||||
'text-danger': isAllTimeLow,
|
[title]="
|
||||||
'text-success': isAllTimeHigh
|
hasError && !isLoading
|
||||||
}"
|
? 'Sorry! Our data provider partner is experiencing the hiccups.'
|
||||||
>
|
: ''
|
||||||
<div class="flex-grow-1"></div>
|
"
|
||||||
|
>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="hasError && !isLoading"
|
||||||
|
name="alert-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
</div>
|
||||||
<div *ngIf="isLoading" class="align-items-center d-flex">
|
<div *ngIf="isLoading" class="align-items-center d-flex">
|
||||||
<ngx-skeleton-loader
|
<ngx-skeleton-loader
|
||||||
animation="pulse"
|
animation="pulse"
|
||||||
@ -20,6 +26,10 @@
|
|||||||
<div
|
<div
|
||||||
class="display-4 font-weight-bold m-0 text-center value-container"
|
class="display-4 font-weight-bold m-0 text-center value-container"
|
||||||
[hidden]="isLoading"
|
[hidden]="isLoading"
|
||||||
|
[ngClass]="{
|
||||||
|
'text-danger': isAllTimeLow,
|
||||||
|
'text-success': isAllTimeHigh
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<span #value id="value"></span>
|
<span #value id="value"></span>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-size: 1.33rem;
|
||||||
|
}
|
||||||
|
|
||||||
.value-container {
|
.value-container {
|
||||||
#value {
|
#value {
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
|
@ -19,6 +19,8 @@ import { isNumber } from 'lodash';
|
|||||||
})
|
})
|
||||||
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||||
@Input() baseCurrency: string;
|
@Input() baseCurrency: string;
|
||||||
|
@Input() deviceType: string;
|
||||||
|
@Input() hasError: boolean;
|
||||||
@Input() isAllTimeHigh: boolean;
|
@Input() isAllTimeHigh: boolean;
|
||||||
@Input() isAllTimeLow: boolean;
|
@Input() isAllTimeLow: boolean;
|
||||||
@Input() isLoading: boolean;
|
@Input() isLoading: boolean;
|
||||||
@ -44,7 +46,11 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
|||||||
this.unit = this.baseCurrency;
|
this.unit = this.baseCurrency;
|
||||||
|
|
||||||
new CountUp('value', this.performance?.currentValue, {
|
new CountUp('value', this.performance?.currentValue, {
|
||||||
decimalPlaces: 2,
|
decimalPlaces:
|
||||||
|
this.deviceType === 'mobile' &&
|
||||||
|
this.performance?.currentValue >= 100000
|
||||||
|
? 0
|
||||||
|
: 2,
|
||||||
duration: 1,
|
duration: 1,
|
||||||
separator: `'`
|
separator: `'`
|
||||||
}).start();
|
}).start();
|
||||||
|
@ -169,4 +169,18 @@
|
|||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col"><hr /></div>
|
||||||
|
</div>
|
||||||
|
<div class="row px-3 py-1">
|
||||||
|
<div class="d-flex flex-grow-1" i18n>Dividend</div>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<gf-value
|
||||||
|
class="justify-content-end"
|
||||||
|
[currency]="baseCurrency"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="isLoading ? undefined : summary?.dividend"
|
||||||
|
></gf-value>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,11 +3,13 @@ import {
|
|||||||
ChangeDetectorRef,
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
Inject,
|
Inject,
|
||||||
OnDestroy
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||||
import { AssetSubClass } from '@prisma/client';
|
import { AssetSubClass } from '@prisma/client';
|
||||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||||
@ -23,7 +25,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
|
|||||||
templateUrl: 'position-detail-dialog.html',
|
templateUrl: 'position-detail-dialog.html',
|
||||||
styleUrls: ['./position-detail-dialog.component.scss']
|
styleUrls: ['./position-detail-dialog.component.scss']
|
||||||
})
|
})
|
||||||
export class PositionDetailDialog implements OnDestroy {
|
export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||||
public assetSubClass: AssetSubClass;
|
public assetSubClass: AssetSubClass;
|
||||||
public averagePrice: number;
|
public averagePrice: number;
|
||||||
public benchmarkDataItems: LineChartItem[];
|
public benchmarkDataItems: LineChartItem[];
|
||||||
@ -39,6 +41,7 @@ export class PositionDetailDialog implements OnDestroy {
|
|||||||
public name: string;
|
public name: string;
|
||||||
public netPerformance: number;
|
public netPerformance: number;
|
||||||
public netPerformancePercent: number;
|
public netPerformancePercent: number;
|
||||||
|
public orders: OrderWithAccount[];
|
||||||
public quantity: number;
|
public quantity: number;
|
||||||
public quantityPrecision = 2;
|
public quantityPrecision = 2;
|
||||||
public symbol: string;
|
public symbol: string;
|
||||||
@ -52,9 +55,11 @@ export class PositionDetailDialog implements OnDestroy {
|
|||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
public dialogRef: MatDialogRef<PositionDetailDialog>,
|
public dialogRef: MatDialogRef<PositionDetailDialog>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
||||||
) {
|
) {}
|
||||||
|
|
||||||
|
public ngOnInit(): void {
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchPositionDetail(data.symbol)
|
.fetchPositionDetail(this.data.symbol)
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe(
|
.subscribe(
|
||||||
({
|
({
|
||||||
@ -72,6 +77,7 @@ export class PositionDetailDialog implements OnDestroy {
|
|||||||
name,
|
name,
|
||||||
netPerformance,
|
netPerformance,
|
||||||
netPerformancePercent,
|
netPerformancePercent,
|
||||||
|
orders,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
symbol,
|
||||||
transactionCount,
|
transactionCount,
|
||||||
@ -104,6 +110,7 @@ export class PositionDetailDialog implements OnDestroy {
|
|||||||
this.name = name;
|
this.name = name;
|
||||||
this.netPerformance = netPerformance;
|
this.netPerformance = netPerformance;
|
||||||
this.netPerformancePercent = netPerformancePercent;
|
this.netPerformancePercent = netPerformancePercent;
|
||||||
|
this.orders = orders;
|
||||||
this.quantity = quantity;
|
this.quantity = quantity;
|
||||||
this.symbol = symbol;
|
this.symbol = symbol;
|
||||||
this.transactionCount = transactionCount;
|
this.transactionCount = transactionCount;
|
||||||
|
@ -12,7 +12,7 @@
|
|||||||
<div class="col-12 d-flex justify-content-center mb-3">
|
<div class="col-12 d-flex justify-content-center mb-3">
|
||||||
<gf-value
|
<gf-value
|
||||||
size="large"
|
size="large"
|
||||||
[currency]="data.baseCurrency"
|
[currency]="currency"
|
||||||
[locale]="data.locale"
|
[locale]="data.locale"
|
||||||
[value]="value"
|
[value]="value"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
@ -124,6 +124,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<gf-activities-table
|
||||||
|
*ngIf="orders?.length > 0"
|
||||||
|
[activities]="orders"
|
||||||
|
[baseCurrency]="data.baseCurrency"
|
||||||
|
[deviceType]="data.deviceType"
|
||||||
|
[hasPermissionToCreateActivity]="false"
|
||||||
|
[hasPermissionToFilter]="false"
|
||||||
|
[hasPermissionToImportActivities]="false"
|
||||||
|
[hasPermissionToOpenDetails]="false"
|
||||||
|
[locale]="data.locale"
|
||||||
|
[showActions]="false"
|
||||||
|
[showSymbolColumn]="false"
|
||||||
|
></gf-activities-table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<gf-dialog-footer
|
<gf-dialog-footer
|
||||||
|
@ -2,12 +2,13 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatDialogModule } from '@angular/material/dialog';
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||||
|
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||||
|
import { GfActivitiesTableModule } from '@ghostfolio/ui/activities-table/activities-table.module';
|
||||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module';
|
|
||||||
import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module';
|
|
||||||
import { PositionDetailDialog } from './position-detail-dialog.component';
|
import { PositionDetailDialog } from './position-detail-dialog.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
@ -15,6 +16,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
|
|||||||
exports: [],
|
exports: [],
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
GfActivitiesTableModule,
|
||||||
GfDialogFooterModule,
|
GfDialogFooterModule,
|
||||||
GfDialogHeaderModule,
|
GfDialogHeaderModule,
|
||||||
GfLineChartModule,
|
GfLineChartModule,
|
||||||
|
@ -5,14 +5,9 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
|
||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
import { Position } from '@ghostfolio/common/interfaces';
|
import { Position } from '@ghostfolio/common/interfaces';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
|
||||||
|
|
||||||
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-position',
|
selector: 'gf-position',
|
||||||
@ -32,23 +27,7 @@ export class PositionComponent implements OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor() {}
|
||||||
private dialog: MatDialog,
|
|
||||||
private route: ActivatedRoute,
|
|
||||||
private router: Router
|
|
||||||
) {
|
|
||||||
route.queryParams
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((params) => {
|
|
||||||
if (
|
|
||||||
params['positionDetailDialog'] &&
|
|
||||||
params['symbol'] &&
|
|
||||||
params['symbol'] === this.position?.symbol
|
|
||||||
) {
|
|
||||||
this.openDialog();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
@ -56,25 +35,4 @@ export class PositionComponent implements OnDestroy, OnInit {
|
|||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private openDialog(): void {
|
|
||||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
|
||||||
autoFocus: false,
|
|
||||||
data: {
|
|
||||||
baseCurrency: this.baseCurrency,
|
|
||||||
deviceType: this.deviceType,
|
|
||||||
locale: this.locale,
|
|
||||||
symbol: this.position?.symbol
|
|
||||||
},
|
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
|
||||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
|
||||||
});
|
|
||||||
|
|
||||||
dialogRef
|
|
||||||
.afterClosed()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.router.navigate(['.'], { relativeTo: this.route });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -123,7 +123,12 @@
|
|||||||
}"
|
}"
|
||||||
></ngx-skeleton-loader>
|
></ngx-skeleton-loader>
|
||||||
|
|
||||||
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center">
|
<div
|
||||||
|
*ngIf="
|
||||||
|
dataSource.data.length === 0 && hasPermissionToCreateOrder && !isLoading
|
||||||
|
"
|
||||||
|
class="p-3 text-center"
|
||||||
|
>
|
||||||
<gf-no-transactions-info-indicator
|
<gf-no-transactions-info-indicator
|
||||||
[hasBorder]="false"
|
[hasBorder]="false"
|
||||||
></gf-no-transactions-info-indicator>
|
></gf-no-transactions-info-indicator>
|
||||||
|
@ -9,17 +9,13 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
|
||||||
import { MatPaginator } from '@angular/material/paginator';
|
import { MatPaginator } from '@angular/material/paginator';
|
||||||
import { MatSort } from '@angular/material/sort';
|
import { MatSort } from '@angular/material/sort';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { AssetClass, Order as OrderModel } from '@prisma/client';
|
import { AssetClass, Order as OrderModel } from '@prisma/client';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { Subject, Subscription } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
|
||||||
|
|
||||||
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-positions-table',
|
selector: 'gf-positions-table',
|
||||||
@ -30,6 +26,7 @@ import { PositionDetailDialog } from '../position/position-detail-dialog/positio
|
|||||||
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||||
@Input() baseCurrency: string;
|
@Input() baseCurrency: string;
|
||||||
@Input() deviceType: string;
|
@Input() deviceType: string;
|
||||||
|
@Input() hasPermissionToCreateOrder: boolean;
|
||||||
@Input() locale: string;
|
@Input() locale: string;
|
||||||
@Input() positions: PortfolioPosition[];
|
@Input() positions: PortfolioPosition[];
|
||||||
|
|
||||||
@ -49,21 +46,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(private router: Router) {}
|
||||||
private dialog: MatDialog,
|
|
||||||
private route: ActivatedRoute,
|
|
||||||
private router: Router
|
|
||||||
) {
|
|
||||||
this.routeQueryParams = route.queryParams
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((params) => {
|
|
||||||
if (params['positionDetailDialog'] && params['symbol']) {
|
|
||||||
this.openPositionDialog({
|
|
||||||
symbol: params['symbol']
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
@ -106,27 +89,6 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public openPositionDialog({ symbol }: { symbol: string }): void {
|
|
||||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
|
||||||
autoFocus: false,
|
|
||||||
data: {
|
|
||||||
symbol,
|
|
||||||
baseCurrency: this.baseCurrency,
|
|
||||||
deviceType: this.deviceType,
|
|
||||||
locale: this.locale
|
|
||||||
},
|
|
||||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
|
||||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
|
||||||
});
|
|
||||||
|
|
||||||
dialogRef
|
|
||||||
.afterClosed()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.router.navigate(['.'], { relativeTo: this.route });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
|
@ -7,12 +7,12 @@ import { MatPaginatorModule } from '@angular/material/paginator';
|
|||||||
import { MatSortModule } from '@angular/material/sort';
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
import { MatTableModule } from '@angular/material/table';
|
import { MatTableModule } from '@angular/material/table';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
|
||||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||||
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
|
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
|
|
||||||
import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
|
|
||||||
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
||||||
import { PositionsTableComponent } from './positions-table.component';
|
import { PositionsTableComponent } from './positions-table.component';
|
||||||
|
|
||||||
|
@ -23,7 +23,10 @@
|
|||||||
[range]="range"
|
[range]="range"
|
||||||
></gf-position>
|
></gf-position>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<div *ngIf="!hasPositions" class="p-3 text-center">
|
<div
|
||||||
|
*ngIf="hasPermissionToCreateOrder && !hasPositions"
|
||||||
|
class="p-3 text-center"
|
||||||
|
>
|
||||||
<gf-no-transactions-info-indicator
|
<gf-no-transactions-info-indicator
|
||||||
[hasBorder]="false"
|
[hasBorder]="false"
|
||||||
></gf-no-transactions-info-indicator>
|
></gf-no-transactions-info-indicator>
|
||||||
|
@ -17,6 +17,7 @@ import { Position } from '@ghostfolio/common/interfaces';
|
|||||||
export class PositionsComponent implements OnChanges, OnInit {
|
export class PositionsComponent implements OnChanges, OnInit {
|
||||||
@Input() baseCurrency: string;
|
@Input() baseCurrency: string;
|
||||||
@Input() deviceType: string;
|
@Input() deviceType: string;
|
||||||
|
@Input() hasPermissionToCreateOrder: boolean;
|
||||||
@Input() locale: string;
|
@Input() locale: string;
|
||||||
@Input() positions: Position[];
|
@Input() positions: Position[];
|
||||||
@Input() range: string;
|
@Input() range: string;
|
||||||
|
@ -1,7 +1,10 @@
|
|||||||
<div class="container p-0">
|
<div class="container p-0">
|
||||||
<div class="row no-gutters">
|
<div class="row no-gutters">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<mat-card *ngIf="rules === null" class="my-2 text-center">
|
<mat-card
|
||||||
|
*ngIf="hasPermissionToCreateOrder && rules === null"
|
||||||
|
class="my-2 text-center"
|
||||||
|
>
|
||||||
<gf-no-transactions-info-indicator
|
<gf-no-transactions-info-indicator
|
||||||
[hasBorder]="false"
|
[hasBorder]="false"
|
||||||
></gf-no-transactions-info-indicator>
|
></gf-no-transactions-info-indicator>
|
||||||
|
@ -8,6 +8,7 @@ import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
|
|||||||
styleUrls: ['./rules.component.scss']
|
styleUrls: ['./rules.component.scss']
|
||||||
})
|
})
|
||||||
export class RulesComponent {
|
export class RulesComponent {
|
||||||
|
@Input() hasPermissionToCreateOrder: boolean;
|
||||||
@Input() rules: PortfolioReportRule;
|
@Input() rules: PortfolioReportRule;
|
||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
@ -8,8 +8,7 @@ import {
|
|||||||
Output
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { FormControl } from '@angular/forms';
|
import { FormControl } from '@angular/forms';
|
||||||
|
import { ToggleOption } from '@ghostfolio/common/types';
|
||||||
import { ToggleOption } from './interfaces/toggle-option.type';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-toggle',
|
selector: 'gf-toggle',
|
||||||
|
@ -16,6 +16,8 @@ import { UserService } from '../services/user/user.service';
|
|||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
private static PUBLIC_PAGE_ROUTES = [
|
private static PUBLIC_PAGE_ROUTES = [
|
||||||
'/about',
|
'/about',
|
||||||
|
'/about/changelog',
|
||||||
|
'/blog',
|
||||||
'/de/blog',
|
'/de/blog',
|
||||||
'/en/blog',
|
'/en/blog',
|
||||||
'/p',
|
'/p',
|
||||||
|
@ -4,8 +4,7 @@ import {
|
|||||||
HttpEvent,
|
HttpEvent,
|
||||||
HttpHandler,
|
HttpHandler,
|
||||||
HttpInterceptor,
|
HttpInterceptor,
|
||||||
HttpRequest,
|
HttpRequest
|
||||||
HttpResponse
|
|
||||||
} from '@angular/common/http';
|
} from '@angular/common/http';
|
||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
@ -14,15 +13,14 @@ import {
|
|||||||
TextOnlySnackBar
|
TextOnlySnackBar
|
||||||
} from '@angular/material/snack-bar';
|
} from '@angular/material/snack-bar';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
import { StatusCodes } from 'http-status-codes';
|
import { StatusCodes } from 'http-status-codes';
|
||||||
import { Observable, throwError } from 'rxjs';
|
import { Observable, throwError } from 'rxjs';
|
||||||
import { catchError, tap } from 'rxjs/operators';
|
import { catchError, tap } from 'rxjs/operators';
|
||||||
|
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
|
||||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HttpResponseInterceptor implements HttpInterceptor {
|
export class HttpResponseInterceptor implements HttpInterceptor {
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
@ -44,26 +42,6 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
|||||||
): Observable<HttpEvent<any>> {
|
): Observable<HttpEvent<any>> {
|
||||||
return next.handle(request).pipe(
|
return next.handle(request).pipe(
|
||||||
tap((event: HttpEvent<any>) => {
|
tap((event: HttpEvent<any>) => {
|
||||||
if (event instanceof HttpResponse) {
|
|
||||||
if (event.status === StatusCodes.ACCEPTED) {
|
|
||||||
if (!this.snackBarRef) {
|
|
||||||
this.snackBarRef = this.snackBar.open(
|
|
||||||
'Sorry! Our data provider partner is experiencing a mild case of the hiccups ;(',
|
|
||||||
'Try again?',
|
|
||||||
{ duration: 6000 }
|
|
||||||
);
|
|
||||||
|
|
||||||
this.snackBarRef.afterDismissed().subscribe(() => {
|
|
||||||
this.snackBarRef = undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.snackBarRef.onAction().subscribe(() => {
|
|
||||||
window.location.reload();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return event;
|
return event;
|
||||||
}),
|
}),
|
||||||
catchError((error: HttpErrorResponse) => {
|
catchError((error: HttpErrorResponse) => {
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
This instance is running Ghostfolio {{ version }} and has been
|
This instance is running Ghostfolio {{ version }} and has been
|
||||||
last published on {{ lastPublish }}.
|
last published on {{ lastPublish }}.
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="hasPermissionForSubscription" i18n
|
<ng-container *ngIf="hasPermissionForStatistics" i18n
|
||||||
>Check the system status at
|
>Check the system status at
|
||||||
<a href="https://status.ghostfol.io" title="Ghostfolio status"
|
<a href="https://status.ghostfol.io" title="Ghostfolio status"
|
||||||
>status.ghostfol.io</a
|
>status.ghostfol.io</a
|
||||||
@ -35,8 +35,8 @@
|
|||||||
new feature, please join the Ghostfolio
|
new feature, please join the Ghostfolio
|
||||||
<a
|
<a
|
||||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||||
title="Join the Ghostfolio Slack channel"
|
title="Join the Ghostfolio Slack community"
|
||||||
>Slack channel</a
|
>Slack community</a
|
||||||
>, tweet to
|
>, tweet to
|
||||||
<a
|
<a
|
||||||
href="https://twitter.com/ghostfolio_"
|
href="https://twitter.com/ghostfolio_"
|
||||||
@ -108,9 +108,7 @@
|
|||||||
<mat-card-content>
|
<mat-card-content>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<h3 class="mb-0" [hidden]="!statistics?.activeUsers1d">
|
<h3 class="mb-0">{{ statistics?.activeUsers1d || '-' }}</h3>
|
||||||
{{ statistics?.activeUsers1d ?? '-' }}
|
|
||||||
</h3>
|
|
||||||
<div class="h6 mb-0">
|
<div class="h6 mb-0">
|
||||||
<span i18n>Active Users</span> <small class="text-muted"
|
<span i18n>Active Users</span> <small class="text-muted"
|
||||||
>(Last 24 hours)</small
|
>(Last 24 hours)</small
|
||||||
@ -118,29 +116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<h3 class="mb-0" [hidden]="!statistics?.activeUsers7d">
|
<h3 class="mb-0">{{ statistics?.newUsers30d ?? '-' }}</h3>
|
||||||
{{ statistics?.activeUsers7d ?? '-' }}
|
|
||||||
</h3>
|
|
||||||
<div class="h6 mb-0">
|
|
||||||
<span i18n>Active Users</span> <small class="text-muted"
|
|
||||||
>(Last 7 days)</small
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
|
||||||
<h3 class="mb-0" [hidden]="!statistics?.activeUsers30d">
|
|
||||||
{{ statistics?.activeUsers30d ?? '-' }}
|
|
||||||
</h3>
|
|
||||||
<div class="h6 mb-0">
|
|
||||||
<span i18n>Active Users</span> <small class="text-muted"
|
|
||||||
>(Last 30 days)</small
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
|
||||||
<h3 class="mb-0" [hidden]="!statistics?.newUsers30d">
|
|
||||||
{{ statistics?.newUsers30d ?? '-' }}
|
|
||||||
</h3>
|
|
||||||
<div class="h6 mb-0">
|
<div class="h6 mb-0">
|
||||||
<span i18n>New Users</span> <small class="text-muted"
|
<span i18n>New Users</span> <small class="text-muted"
|
||||||
>(Last 30 days)</small
|
>(Last 30 days)</small
|
||||||
@ -148,15 +124,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<h3 class="mb-0" [hidden]="!statistics?.gitHubContributors">
|
<h3 class="mb-0">{{ statistics?.activeUsers30d ?? '-' }}</h3>
|
||||||
{{ statistics?.gitHubContributors ?? '-' }}
|
<div class="h6 mb-0">
|
||||||
</h3>
|
<span i18n>Active Users</span> <small class="text-muted"
|
||||||
|
>(Last 30 days)</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
|
<h3 class="mb-0">{{ statistics?.slackCommunityUsers ?? '-' }}</h3>
|
||||||
|
<div class="h6 mb-0" i18n>Users in Slack community</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
|
<h3 class="mb-0">{{ statistics?.gitHubContributors ?? '-' }}</h3>
|
||||||
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-xs-12 col-md-4 my-2">
|
<div class="col-xs-12 col-md-4 my-2">
|
||||||
<h3 class="mb-0" [hidden]="!statistics?.gitHubStargazers">
|
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
|
||||||
{{ statistics?.gitHubStargazers ?? '-' }}
|
|
||||||
</h3>
|
|
||||||
<div class="h6 mb-0" i18n>Stars on GitHub</div>
|
<div class="h6 mb-0" i18n>Stars on GitHub</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -165,73 +149,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div *ngIf="hasPermissionForBlog" class="mb-5 row">
|
|
||||||
<div class="col">
|
|
||||||
<h3 class="mb-3 text-center" i18n>Blog</h3>
|
|
||||||
<mat-card class="blog-container">
|
|
||||||
<mat-card-content>
|
|
||||||
<div class="container p-0">
|
|
||||||
<div class="flex-nowrap mb-3 no-gutters row">
|
|
||||||
<a
|
|
||||||
class="d-flex w-100"
|
|
||||||
[routerLink]="['/en', 'blog', '2021', '07', 'hello-ghostfolio']"
|
|
||||||
>
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<div class="h6 m-0 text-truncate">Hello Ghostfolio</div>
|
|
||||||
<div class="d-flex text-muted">31.07.2021</div>
|
|
||||||
</div>
|
|
||||||
<div class="align-items-center d-flex">
|
|
||||||
<ion-icon
|
|
||||||
class="chevron text-muted"
|
|
||||||
name="chevron-forward-outline"
|
|
||||||
size="small"
|
|
||||||
></ion-icon>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div class="flex-nowrap no-gutters row">
|
|
||||||
<a
|
|
||||||
class="d-flex w-100"
|
|
||||||
[routerLink]="['/de', 'blog', '2021', '07', 'hallo-ghostfolio']"
|
|
||||||
>
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<div class="h6 m-0 text-truncate">Hallo Ghostfolio</div>
|
|
||||||
<div class="d-flex text-muted">31.07.2021</div>
|
|
||||||
</div>
|
|
||||||
<div class="align-items-center d-flex">
|
|
||||||
<ion-icon
|
|
||||||
class="chevron text-muted"
|
|
||||||
name="chevron-forward-outline"
|
|
||||||
size="small"
|
|
||||||
></ion-icon>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-5 row">
|
|
||||||
<div class="col">
|
|
||||||
<h3 class="mb-3 text-center" i18n>Changelog</h3>
|
|
||||||
<mat-card class="changelog">
|
|
||||||
<mat-card-content>
|
|
||||||
<markdown [src]="'assets/CHANGELOG.md'"></markdown>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2">
|
||||||
<h3 class="mb-3 text-center" i18n>License</h3>
|
<a class="py-2 w-100" i18n mat-stroked-button [routerLink]="['/blog']"
|
||||||
<mat-card>
|
>Blog</a
|
||||||
<mat-card-content>
|
>
|
||||||
<markdown [src]="'assets/LICENSE'"></markdown>
|
</div>
|
||||||
</mat-card-content>
|
<div
|
||||||
</mat-card>
|
class="col-md-6 col-xs-12 my-2"
|
||||||
|
[ngClass]="{ 'offset-md-3': !hasPermissionForBlog }"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="py-2 w-100"
|
||||||
|
i18n
|
||||||
|
mat-stroked-button
|
||||||
|
[routerLink]="['/about', 'changelog']"
|
||||||
|
>Changelog & License</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MarkdownModule } from 'ngx-markdown';
|
|
||||||
|
|
||||||
import { AboutPageRoutingModule } from './about-page-routing.module';
|
import { AboutPageRoutingModule } from './about-page-routing.module';
|
||||||
import { AboutPageComponent } from './about-page.component';
|
import { AboutPageComponent } from './about-page.component';
|
||||||
@ -13,7 +12,6 @@ import { AboutPageComponent } from './about-page.component';
|
|||||||
imports: [
|
imports: [
|
||||||
AboutPageRoutingModule,
|
AboutPageRoutingModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
MarkdownModule.forChild(),
|
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule
|
MatCardModule
|
||||||
],
|
],
|
||||||
|
@ -0,0 +1,15 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
|
import { ChangelogPageComponent } from './changelog-page.component';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{ path: '', component: ChangelogPageComponent, canActivate: [AuthGuard] }
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class ChangelogPageRoutingModule {}
|
@ -0,0 +1,22 @@
|
|||||||
|
import { Component, OnDestroy } from '@angular/core';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'mb-5' },
|
||||||
|
selector: 'gf-changelog-page',
|
||||||
|
styleUrls: ['./changelog-page.scss'],
|
||||||
|
templateUrl: './changelog-page.html'
|
||||||
|
})
|
||||||
|
export class ChangelogPageComponent implements OnDestroy {
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor() {}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,23 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="mb-5 row">
|
||||||
|
<div class="col">
|
||||||
|
<h3 class="mb-3 text-center" i18n>Changelog</h3>
|
||||||
|
<mat-card class="changelog">
|
||||||
|
<mat-card-content>
|
||||||
|
<markdown [src]="'assets/CHANGELOG.md'"></markdown>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<h3 class="mb-3 text-center" i18n>License</h3>
|
||||||
|
<mat-card>
|
||||||
|
<mat-card-content>
|
||||||
|
<markdown [src]="'assets/LICENSE'"></markdown>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,19 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { MarkdownModule } from 'ngx-markdown';
|
||||||
|
|
||||||
|
import { ChangelogPageRoutingModule } from './changelog-page-routing.module';
|
||||||
|
import { ChangelogPageComponent } from './changelog-page.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [ChangelogPageComponent],
|
||||||
|
imports: [
|
||||||
|
ChangelogPageRoutingModule,
|
||||||
|
CommonModule,
|
||||||
|
MarkdownModule.forChild(),
|
||||||
|
MatCardModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class ChangelogPageModule {}
|
@ -0,0 +1,44 @@
|
|||||||
|
:host {
|
||||||
|
color: rgb(var(--dark-primary-text));
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-card {
|
||||||
|
&.changelog {
|
||||||
|
a {
|
||||||
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(var(--palette-primary-300), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.changelog {
|
||||||
|
::ng-deep {
|
||||||
|
markdown {
|
||||||
|
h1,
|
||||||
|
p {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
|
||||||
|
&:not(:first-of-type) {
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.is-dark-theme) {
|
||||||
|
color: rgb(var(--light-primary-text));
|
||||||
|
}
|
@ -10,6 +10,11 @@ import {
|
|||||||
MatSlideToggle,
|
MatSlideToggle,
|
||||||
MatSlideToggleChange
|
MatSlideToggleChange
|
||||||
} from '@angular/material/slide-toggle';
|
} from '@angular/material/slide-toggle';
|
||||||
|
import {
|
||||||
|
MatSnackBar,
|
||||||
|
MatSnackBarRef,
|
||||||
|
TextOnlySnackBar
|
||||||
|
} from '@angular/material/snack-bar';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
@ -49,6 +54,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
public hasPermissionToUpdateUserSettings: boolean;
|
public hasPermissionToUpdateUserSettings: boolean;
|
||||||
public price: number;
|
public price: number;
|
||||||
public priceId: string;
|
public priceId: string;
|
||||||
|
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
|
||||||
public user: User;
|
public user: User;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
@ -61,6 +67,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
private deviceService: DeviceDetectorService,
|
private deviceService: DeviceDetectorService,
|
||||||
private dialog: MatDialog,
|
private dialog: MatDialog,
|
||||||
|
private snackBar: MatSnackBar,
|
||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private stripeService: StripeService,
|
private stripeService: StripeService,
|
||||||
@ -185,6 +192,49 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onRedeemCoupon() {
|
||||||
|
let couponCode = prompt('Please enter your coupon code:');
|
||||||
|
couponCode = couponCode?.trim();
|
||||||
|
|
||||||
|
if (couponCode) {
|
||||||
|
this.dataService
|
||||||
|
.redeemCoupon(couponCode)
|
||||||
|
.pipe(
|
||||||
|
takeUntil(this.unsubscribeSubject),
|
||||||
|
catchError(() => {
|
||||||
|
this.snackBar.open('😞 Could not redeem coupon code', undefined, {
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
this.snackBarRef = this.snackBar.open(
|
||||||
|
'✅ Coupon code has been redeemed',
|
||||||
|
'Reload',
|
||||||
|
{
|
||||||
|
duration: 3000
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.snackBarRef
|
||||||
|
.afterDismissed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.snackBarRef
|
||||||
|
.onAction()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
|
public onRestrictedViewChange(aEvent: MatSlideToggleChange) {
|
||||||
this.dataService
|
this.dataService
|
||||||
.putUserSetting({ isRestrictedView: aEvent.checked })
|
.putUserSetting({ isRestrictedView: aEvent.checked })
|
||||||
|
@ -47,6 +47,13 @@
|
|||||||
<ng-container *ngIf="!coupon">{{ price }}</ng-container>
|
<ng-container *ngIf="!coupon">{{ price }}</ng-container>
|
||||||
<span i18n> per year</span>
|
<span i18n> per year</span>
|
||||||
</div>
|
</div>
|
||||||
|
<a
|
||||||
|
class="cursor-pointer d-block mt-2"
|
||||||
|
i18n
|
||||||
|
[routerLink]=""
|
||||||
|
(click)="onRedeemCoupon()"
|
||||||
|
>Redeem Coupon</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -8,6 +8,7 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
|||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
|
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
|
||||||
|
|
||||||
import { AccountPageRoutingModule } from './account-page-routing.module';
|
import { AccountPageRoutingModule } from './account-page-routing.module';
|
||||||
@ -30,7 +31,8 @@ import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-di
|
|||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
MatSlideToggleModule,
|
MatSlideToggleModule,
|
||||||
ReactiveFormsModule
|
ReactiveFormsModule,
|
||||||
|
RouterModule
|
||||||
],
|
],
|
||||||
providers: []
|
providers: []
|
||||||
})
|
})
|
||||||
|
@ -2,6 +2,15 @@
|
|||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: rgba(var(--palette-primary-300), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
gf-access-table {
|
gf-access-table {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<div class="blog container">
|
<div class="blog container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col-md-8 offset-md-2">
|
||||||
<article>
|
<article>
|
||||||
<div class="mb-4 text-center">
|
<div class="mb-4 text-center">
|
||||||
<h1 class="mb-1" i18n>Hallo Ghostfolio 👋</h1>
|
<h1 class="mb-1" i18n>Hallo Ghostfolio 👋</h1>
|
||||||
@ -141,58 +141,59 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="mb-4">
|
<section class="mb-4">
|
||||||
<ul class="list-inline">
|
<ul class="list-inline">
|
||||||
<li class="h5">
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light">Aktie</span>
|
||||||
>Aktie</span
|
</li>
|
||||||
>
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light">Altersvorsorge</span>
|
||||||
>Altersvorsorge</span
|
</li>
|
||||||
>
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light">Anlage</span>
|
||||||
>Anlage</span
|
</li>
|
||||||
>
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2">App</span>
|
<span class="badge badge-light">App</span>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
</li>
|
||||||
>Cryptocurrency</span
|
<li class="list-inline-item">
|
||||||
>
|
<span class="badge badge-light">Cryptocurrency</span>
|
||||||
<span class="badge badge-light font-weight-normal mr-2">ETF</span>
|
</li>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<li class="list-inline-item">
|
||||||
>Feedback</span
|
<span class="badge badge-light">Feedback</span>
|
||||||
>
|
</li>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<li class="list-inline-item">
|
||||||
>Fintech</span
|
<span class="badge badge-light">Fintech</span>
|
||||||
>
|
</li>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<li class="list-inline-item">
|
||||||
>Ghostfolio</span
|
<span class="badge badge-light">Ghostfolio</span>
|
||||||
>
|
</li>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<li class="list-inline-item">
|
||||||
>Investition</span
|
<span class="badge badge-light">Investition</span>
|
||||||
>
|
</li>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<li class="list-inline-item">
|
||||||
>Open Source</span
|
<span class="badge badge-light">Open Source</span>
|
||||||
>
|
</li>
|
||||||
<span class="badge badge-light font-weight-normal mr-2">OSS</span>
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light">OSS</span>
|
||||||
>Portfolio</span
|
</li>
|
||||||
>
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light">Portfolio</span>
|
||||||
>Software</span
|
</li>
|
||||||
>
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light">Software</span>
|
||||||
>Strategie</span
|
</li>
|
||||||
>
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light">Strategie</span>
|
||||||
>Trading</span
|
</li>
|
||||||
>
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light">Trading</span>
|
||||||
>TypeScript</span
|
</li>
|
||||||
>
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light">TypeScript</span>
|
||||||
>Vermögen</span
|
</li>
|
||||||
>
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light">Vermögen</span>
|
||||||
>Wealth Management</span
|
</li>
|
||||||
>
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Wealth Management</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
@ -7,9 +7,7 @@ import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component'
|
|||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [HalloGhostfolioPageComponent],
|
declarations: [HalloGhostfolioPageComponent],
|
||||||
exports: [],
|
|
||||||
imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule],
|
imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule],
|
||||||
providers: [],
|
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
})
|
})
|
||||||
export class HalloGhostfolioPageModule {}
|
export class HalloGhostfolioPageModule {}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<div class="blog container">
|
<div class="blog container">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col-md-8 offset-md-2">
|
||||||
<article>
|
<article>
|
||||||
<div class="mb-4 text-center">
|
<div class="mb-4 text-center">
|
||||||
<h1 class="mb-1" i18n>Hello Ghostfolio 👋</h1>
|
<h1 class="mb-1" i18n>Hello Ghostfolio 👋</h1>
|
||||||
@ -136,42 +136,44 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="mb-4">
|
<section class="mb-4">
|
||||||
<ul class="list-inline">
|
<ul class="list-inline">
|
||||||
<li class="h5">
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<span class="badge badge-light">Cryptocurrency</span>
|
||||||
>Cryptocurrency</span
|
</li>
|
||||||
>
|
<li class="list-inline-item">
|
||||||
<span class="badge badge-light font-weight-normal mr-2">ETF</span>
|
<span class="badge badge-light">ETF</span>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
</li>
|
||||||
>Fintech</span
|
<li class="list-inline-item">
|
||||||
>
|
<span class="badge badge-light">Fintech</span>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
</li>
|
||||||
>Ghostfolio</span
|
<li class="list-inline-item">
|
||||||
>
|
<span class="badge badge-light">Ghostfolio</span>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
</li>
|
||||||
>Investment</span
|
<li class="list-inline-item">
|
||||||
>
|
<span class="badge badge-light">Investment</span>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
</li>
|
||||||
>Open Source</span
|
<li class="list-inline-item">
|
||||||
>
|
<span class="badge badge-light">Open Source</span>
|
||||||
<span class="badge badge-light font-weight-normal mr-2">OSS</span>
|
</li>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<li class="list-inline-item">
|
||||||
>Portfolio</span
|
<span class="badge badge-light">OSS</span>
|
||||||
>
|
</li>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<li class="list-inline-item">
|
||||||
>Software</span
|
<span class="badge badge-light">Portfolio</span>
|
||||||
>
|
</li>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<li class="list-inline-item">
|
||||||
>Stock</span
|
<span class="badge badge-light">Software</span>
|
||||||
>
|
</li>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<li class="list-inline-item">
|
||||||
>Strategy</span
|
<span class="badge badge-light">Stock</span>
|
||||||
>
|
</li>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<li class="list-inline-item">
|
||||||
>Wealth</span
|
<span class="badge badge-light">Strategy</span>
|
||||||
>
|
</li>
|
||||||
<span class="badge badge-light font-weight-normal mr-2"
|
<li class="list-inline-item">
|
||||||
>Wealth Management</span
|
<span class="badge badge-light">Wealth</span>
|
||||||
>
|
</li>
|
||||||
|
<li class="list-inline-item">
|
||||||
|
<span class="badge badge-light">Wealth Management</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user