Compare commits
72 Commits
Author | SHA1 | Date | |
---|---|---|---|
74954bc51d | |||
6a03120225 | |||
21504573b4 | |||
fabd912fba | |||
00b42855b6 | |||
ef272360fb | |||
026a5011d4 | |||
aa4206af0e | |||
7788465272 | |||
3066dfd805 | |||
34303163bc | |||
e7fbcd4fa0 | |||
7c22969de1 | |||
6623bc0113 | |||
146b5201b5 | |||
b021fbde59 | |||
ec046b81a7 | |||
aea497154a | |||
dc736d53b4 | |||
5957b33779 | |||
bafdce56ad | |||
42a2d404e4 | |||
11b2379d98 | |||
c0657a2e9e | |||
646dcb91c5 | |||
ad961f3039 | |||
c16f743b07 | |||
8e13f6ef9b | |||
95bcdea69b | |||
0d6fe4a232 | |||
ced4519412 | |||
ef8b7718b1 | |||
b4762dc463 | |||
9851cce382 | |||
a1460a98fd | |||
1a553a296f | |||
f5bd6b0d58 | |||
78a4946e8b | |||
702ee956a2 | |||
200a7d2d65 | |||
79edc09710 | |||
77255df4be | |||
277133fa1a | |||
abd0e08566 | |||
561d8dbc70 | |||
c973ffd3ba | |||
368de7dedc | |||
e56514629f | |||
7a8a25c4c0 | |||
5d36d3a6bb | |||
0ef35fd31f | |||
c1c22c195d | |||
111d8d8e3c | |||
b0a24e4fc0 | |||
694b9b8991 | |||
fada347aa5 | |||
f37ea9f0e7 | |||
59911925c2 | |||
58fd59beb1 | |||
eb09d77251 | |||
42b9178d96 | |||
45516311f5 | |||
04cfa7366f | |||
4234ab84a9 | |||
b8c05d1014 | |||
91ec9aa0a4 | |||
565e920f1b | |||
5d24adfa75 | |||
1dc94c0027 | |||
ebae2f4ec9 | |||
7099edc591 | |||
de973d6bda |
4
.env
4
.env
@ -5,12 +5,12 @@ REDIS_HOST=localhost
|
|||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
# POSTGRES
|
# POSTGRES
|
||||||
|
POSTGRES_DB=ghostfolio-db
|
||||||
POSTGRES_USER=user
|
POSTGRES_USER=user
|
||||||
POSTGRES_PASSWORD=password
|
POSTGRES_PASSWORD=password
|
||||||
POSTGRES_DB=ghostfolio-db
|
|
||||||
|
|
||||||
ACCESS_TOKEN_SALT=GHOSTFOLIO
|
ACCESS_TOKEN_SALT=GHOSTFOLIO
|
||||||
ALPHA_VANTAGE_API_KEY=
|
ALPHA_VANTAGE_API_KEY=
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/ghostfolio-db?sslmode=prefer
|
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||||
JWT_SECRET_KEY=123456
|
JWT_SECRET_KEY=123456
|
||||||
PORT=3333
|
PORT=3333
|
||||||
|
11
.travis.yml
Normal file
11
.travis.yml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
language: node_js
|
||||||
|
git:
|
||||||
|
depth: false
|
||||||
|
node_js:
|
||||||
|
- 14
|
||||||
|
before_script:
|
||||||
|
- yarn
|
||||||
|
script:
|
||||||
|
- yarn format:check
|
||||||
|
- yarn test
|
||||||
|
- yarn build:all
|
155
CHANGELOG.md
155
CHANGELOG.md
@ -5,6 +5,161 @@ 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.12.0 - 06.06.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a symbol profile model with additional data
|
||||||
|
- Added new pie charts: Positions by continent and country
|
||||||
|
|
||||||
|
## 1.11.0 - 05.06.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a dedicated page for the account registration
|
||||||
|
- Rendered the average buy prices in the position detail chart (useful for recurring transactions)
|
||||||
|
- Introduced the initial prisma migration
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the buttons to links (`<a>`) on the tools page
|
||||||
|
- Upgraded `prisma` from version `2.20.1` to `2.24.1`
|
||||||
|
|
||||||
|
## 1.10.1 - 02.06.2021
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an optional type in the user interface
|
||||||
|
|
||||||
|
## 1.10.0 - 02.06.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Moved the tools to a sub path (`/tools`)
|
||||||
|
- Extended the pricing page and aligned with the subscription model
|
||||||
|
|
||||||
|
## 1.9.0 - 01.06.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the year labels to the investment chart on the x-axis
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Respected the data source attribute of the transactions model in the data management for historical data
|
||||||
|
- Prettified the generic scraper symbols in the transaction filtering component
|
||||||
|
- Changed to the strict mode of distance formatting between two given dates
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the sorting in various tables
|
||||||
|
- Made the order of the rules in the _X-ray_ section consistent
|
||||||
|
|
||||||
|
## 1.8.0 - 24.05.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a section for _Analysis_, _X-ray_ and upcoming tools
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Introduced a user service implemented as an observable store (single source of truth for state)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the performance chart by considering the investment
|
||||||
|
- Fixed missing header of public pages (_About_, _Pricing_, _Resources_)
|
||||||
|
|
||||||
|
## 1.7.0 - 22.05.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Hid footer on mobile (except on landing page)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the internal navigation of the _Zen Mode_ in combination with a query parameter
|
||||||
|
|
||||||
|
## 1.6.0 - 22.05.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an index in the user table of the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the alignment in the user table of the admin control panel
|
||||||
|
|
||||||
|
## 1.5.0 - 22.05.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added _Zen Mode_: the distraction-free view
|
||||||
|
|
||||||
|
## 1.4.0 - 20.05.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added filtering by year in the transaction filtering component
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Renamed _Ghostfolio Account_ to _My Ghostfolio_
|
||||||
|
- Hid unknown exchange in the position overview
|
||||||
|
- Disable the base currency selector for the demo user
|
||||||
|
- Refactored the portfolio unit tests to work without database
|
||||||
|
- Refactored the search functionality of the data management (aligned with data source)
|
||||||
|
- Renamed shared helper to `@ghostfolio/common/helper`
|
||||||
|
- Moved shared interfaces to `@ghostfolio/common/interfaces`
|
||||||
|
- Moved shared types to `@ghostfolio/common/types`
|
||||||
|
|
||||||
|
## 1.3.0 - 15.05.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refactored the active menu item state by parsing the current url
|
||||||
|
- Used a desaturated background color for unknown types in pie charts
|
||||||
|
- Renamed the columns _Initial Share_ and _Current Share_ to _Initial Allocation_ and _Current Allocation_ in the positions table
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the link to the pricing page
|
||||||
|
|
||||||
|
## 1.2.1 - 14.05.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Updated the sitemap
|
||||||
|
|
||||||
|
## 1.2.0 - 14.05.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Harmonized the style of various tables
|
||||||
|
- Keep the color per type when switching between _Initial_ and _Current_ in pie charts
|
||||||
|
- Upgraded `chart.js` from version `3.0.2` to `3.2.1`
|
||||||
|
- Moved the pricing section to a dedicated page
|
||||||
|
- Improved the style of the transaction filtering component
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the tooltips when switching between _Initial_ and _Current_ in pie charts
|
||||||
|
|
||||||
|
## 1.1.0 - 11.05.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a button to fetch the current market price in the create or edit transaction dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the transaction filtering with multi filter support
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the filtering by account name in the transactions table
|
||||||
|
- Fixed the active menu item state when a modal has opened
|
||||||
|
|
||||||
## 1.0.0 - 05.05.2021
|
## 1.0.0 - 05.05.2021
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
34
README.md
34
README.md
@ -7,13 +7,15 @@
|
|||||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
|
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
|
||||||
|
<a href="https://travis-ci.org/github/ghostfolio/ghostfolio" rel="nofollow">
|
||||||
|
<img src="https://travis-ci.org/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
|
||||||
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
|
||||||
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3">
|
<img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3"/></a>
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**Ghostfolio** is an open source portfolio tracker. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
|
**Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
|
||||||
|
|
||||||
## Why Ghostfolio?
|
## Why Ghostfolio?
|
||||||
|
|
||||||
@ -40,10 +42,13 @@ Ghostfolio is for you if you are...
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
|
- ✅ Multi account management
|
||||||
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
|
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
|
||||||
- ✅ Various charts
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
- ✅ Dark Mode
|
- ✅ Dark Mode
|
||||||
|
- ✅ Zen Mode
|
||||||
|
- ✅ Mobile-first design
|
||||||
|
|
||||||
## Technology Stack
|
## Technology Stack
|
||||||
|
|
||||||
@ -51,11 +56,11 @@ Ghostfolio is a modern web application written in [TypeScript](https://www.types
|
|||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database and [Redis](https://redis.io) for caching.
|
The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://www.postgresql.org) as a database together with [Prisma](https://www.prisma.io) and [Redis](https://redis.io) for caching.
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
The frontend is built with [Angular](https://angular.io).
|
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).
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
@ -68,19 +73,18 @@ The frontend is built with [Angular](https://angular.io).
|
|||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
1. Run `yarn install`
|
1. Run `yarn install`
|
||||||
2. Run `cd docker`
|
1. Run `cd docker`
|
||||||
3. Run `docker compose build`
|
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
4. 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
|
||||||
5. Run `cd -` to go back to the project root directory
|
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
||||||
6. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
1. Start server and client (see [_Development_](#Development))
|
||||||
7. Start server and client (see _Development_)
|
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||||
8. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
1. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
|
||||||
9. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
|
1. Press _Sign out_ and check out the _Live Demo_
|
||||||
10. Press _Sign out_ and check out the _Live Demo_
|
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Please make sure you have completed the instructions from _Setup_
|
Please make sure you have completed the instructions from [_Setup_](#Setup)
|
||||||
|
|
||||||
### Start server
|
### Start server
|
||||||
|
|
||||||
|
12
angular.json
12
angular.json
@ -208,22 +208,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"helper": {
|
"common": {
|
||||||
"root": "libs/helper",
|
"root": "libs/common",
|
||||||
"sourceRoot": "libs/helper/src",
|
"sourceRoot": "libs/common/src",
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
"architect": {
|
"architect": {
|
||||||
"lint": {
|
"lint": {
|
||||||
"builder": "@nrwl/linter:eslint",
|
"builder": "@nrwl/linter:eslint",
|
||||||
"options": {
|
"options": {
|
||||||
"lintFilePatterns": ["libs/helper/**/*.ts"]
|
"lintFilePatterns": ["libs/common/**/*.ts"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"test": {
|
"test": {
|
||||||
"builder": "@nrwl/jest:jest",
|
"builder": "@nrwl/jest:jest",
|
||||||
"outputs": ["coverage/libs/helper"],
|
"outputs": ["coverage/libs/common"],
|
||||||
"options": {
|
"options": {
|
||||||
"jestConfig": "libs/helper/jest.config.js",
|
"jestConfig": "libs/common/jest.config.js",
|
||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
import { AccessService } from './access.service';
|
import { AccessService } from './access.service';
|
||||||
import { Access } from './interfaces/access.interface';
|
|
||||||
|
|
||||||
@Controller('access')
|
@Controller('access')
|
||||||
export class AccessController {
|
export class AccessController {
|
||||||
|
@ -1,9 +1,8 @@
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { AccessWithGranteeUser } from './interfaces/access-with-grantee-user.type';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AccessService {
|
export class AccessService {
|
||||||
public constructor(private prisma: PrismaService) {}
|
public constructor(private prisma: PrismaService) {}
|
||||||
|
@ -1,7 +1,11 @@
|
|||||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
import {
|
||||||
|
getPermissions,
|
||||||
|
hasPermission,
|
||||||
|
permissions
|
||||||
|
} from '@ghostfolio/common/permissions';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -17,7 +21,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 { Account as AccountModel, Order } from '@prisma/client';
|
import { Account as AccountModel } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
getPermissions,
|
||||||
|
hasPermission,
|
||||||
|
permissions
|
||||||
|
} from '@ghostfolio/common/permissions';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -14,7 +19,6 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
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 { AdminData } from './interfaces/admin-data.interface';
|
|
||||||
|
|
||||||
@Controller('admin')
|
@Controller('admin')
|
||||||
export class AdminController {
|
export class AdminController {
|
||||||
|
@ -1,10 +1,9 @@
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
import { AdminData } from './interfaces/admin-data.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminService {
|
export class AdminService {
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -109,7 +108,7 @@ export class AdminService {
|
|||||||
createdAt: true,
|
createdAt: true,
|
||||||
id: true
|
id: true
|
||||||
},
|
},
|
||||||
take: 20,
|
take: 30,
|
||||||
where: {
|
where: {
|
||||||
NOT: {
|
NOT: {
|
||||||
Analytics: null
|
Analytics: null
|
||||||
|
4
apps/api/src/app/cache/cache.controller.ts
vendored
4
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,5 +1,5 @@
|
|||||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Controller, Inject, Param, Post, UseGuards } from '@nestjs/common';
|
import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
|
||||||
import {
|
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
|
||||||
baseCurrency,
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
benchmarks,
|
|
||||||
isApiTokenAuthorized
|
|
||||||
} from '@ghostfolio/helper';
|
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -40,7 +37,9 @@ export class ExperimentalController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return benchmarks;
|
return benchmarks.map(({ symbol }) => {
|
||||||
|
return symbol;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('benchmarks/:symbol')
|
@Get('benchmarks/:symbol')
|
||||||
|
@ -3,11 +3,11 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider.serv
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||||
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency, Type } from '@prisma/client';
|
import { Currency, Type } from '@prisma/client';
|
||||||
import { parseISO } from 'date-fns';
|
import { parseISO } from 'date-fns';
|
||||||
|
|
||||||
import { OrderWithAccount } from '../order/interfaces/order-with-account.type';
|
|
||||||
import { CreateOrderDto } from './create-order.dto';
|
import { CreateOrderDto } from './create-order.dto';
|
||||||
import { Data } from './interfaces/data.interface';
|
import { Data } from './interfaces/data.interface';
|
||||||
|
|
||||||
@ -44,6 +44,7 @@ export class ExperimentalService {
|
|||||||
fee: 0,
|
fee: 0,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
platformId: undefined,
|
platformId: undefined,
|
||||||
|
symbolProfileId: undefined,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
updatedAt: undefined,
|
updatedAt: undefined,
|
||||||
userId: undefined
|
userId: undefined
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
import { Controller, Get } from '@nestjs/common';
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
|
||||||
import { InfoService } from './info.service';
|
import { InfoService } from './info.service';
|
||||||
import { InfoItem } from './interfaces/info-item.interface';
|
|
||||||
|
|
||||||
@Controller('info')
|
@Controller('info')
|
||||||
export class InfoController {
|
export class InfoController {
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
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 { permissions } from '@ghostfolio/helper';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
import { InfoItem } from './interfaces/info-item.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
import { Account, Settings, User } from '@prisma/client';
|
|
||||||
|
|
||||||
export type UserWithSettings = User & {
|
|
||||||
Account: Account[];
|
|
||||||
Settings: Settings;
|
|
||||||
};
|
|
@ -1,3 +0,0 @@
|
|||||||
import { Account, Order } from '@prisma/client';
|
|
||||||
|
|
||||||
export type OrderWithAccount = Order & { Account?: Account };
|
|
@ -1,7 +1,11 @@
|
|||||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
import {
|
||||||
|
getPermissions,
|
||||||
|
hasPermission,
|
||||||
|
permissions
|
||||||
|
} from '@ghostfolio/common/permissions';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Order, Prisma } from '@prisma/client';
|
import { DataSource, Order, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { CacheService } from '../cache/cache.service';
|
import { CacheService } from '../cache/cache.service';
|
||||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||||
import { OrderWithAccount } from './interfaces/order-with-account.type';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OrderService {
|
export class OrderService {
|
||||||
@ -53,6 +53,7 @@ export class OrderService {
|
|||||||
// Gather symbol data of order in the background
|
// Gather symbol data of order in the background
|
||||||
this.dataGatheringService.gatherSymbols([
|
this.dataGatheringService.gatherSymbols([
|
||||||
{
|
{
|
||||||
|
dataSource: data.dataSource,
|
||||||
date: <Date>data.date,
|
date: <Date>data.date,
|
||||||
symbol: data.symbol
|
symbol: data.symbol
|
||||||
}
|
}
|
||||||
@ -90,6 +91,7 @@ export class OrderService {
|
|||||||
// Gather symbol data of order in the background
|
// Gather symbol data of order in the background
|
||||||
this.dataGatheringService.gatherSymbols([
|
this.dataGatheringService.gatherSymbols([
|
||||||
{
|
{
|
||||||
|
dataSource: <DataSource>data.dataSource,
|
||||||
date: <Date>data.date,
|
date: <Date>data.date,
|
||||||
symbol: <string>data.symbol
|
symbol: <string>data.symbol
|
||||||
}
|
}
|
||||||
|
@ -4,7 +4,19 @@ import {
|
|||||||
} from '@ghostfolio/api/helper/object.helper';
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
import {
|
||||||
|
PortfolioItem,
|
||||||
|
PortfolioOverview,
|
||||||
|
PortfolioPerformance,
|
||||||
|
PortfolioPosition,
|
||||||
|
PortfolioReport
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
getPermissions,
|
||||||
|
hasPermission,
|
||||||
|
permissions
|
||||||
|
} from '@ghostfolio/common/permissions';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -21,16 +33,10 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { RequestWithUser } from '../interfaces/request-with-user.type';
|
|
||||||
import { PortfolioItem } from './interfaces/portfolio-item.interface';
|
|
||||||
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
|
|
||||||
import { PortfolioPerformance } from './interfaces/portfolio-performance.interface';
|
|
||||||
import {
|
import {
|
||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
PortfolioPositionDetail
|
PortfolioPositionDetail
|
||||||
} from './interfaces/portfolio-position-detail.interface';
|
} from './interfaces/portfolio-position-detail.interface';
|
||||||
import { PortfolioPosition } from './interfaces/portfolio-position.interface';
|
|
||||||
import { PortfolioReport } from './interfaces/portfolio-report.interface';
|
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
@Controller('portfolio')
|
@Controller('portfolio')
|
||||||
@ -309,18 +315,6 @@ export class PortfolioController {
|
|||||||
impersonationUserId || this.request.user.id
|
impersonationUserId || this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let report = await portfolio.getReport();
|
return await portfolio.getReport();
|
||||||
|
|
||||||
if (
|
|
||||||
impersonationId &&
|
|
||||||
!hasPermission(
|
|
||||||
getPermissions(this.request.user.role),
|
|
||||||
permissions.readForeignPortfolio
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
// TODO: Filter out absolute numbers
|
|
||||||
}
|
|
||||||
|
|
||||||
return report;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
|
||||||
import { Portfolio } from '@ghostfolio/api/models/portfolio';
|
import { Portfolio } from '@ghostfolio/api/models/portfolio';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||||
|
import {
|
||||||
|
PortfolioItem,
|
||||||
|
PortfolioOverview
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
format,
|
format,
|
||||||
@ -15,6 +20,7 @@ import {
|
|||||||
getYear,
|
getYear,
|
||||||
isAfter,
|
isAfter,
|
||||||
isSameDay,
|
isSameDay,
|
||||||
|
parse,
|
||||||
parseISO,
|
parseISO,
|
||||||
setDate,
|
setDate,
|
||||||
setMonth,
|
setMonth,
|
||||||
@ -26,9 +32,6 @@ import * as roundTo from 'round-to';
|
|||||||
import { OrderService } from '../order/order.service';
|
import { OrderService } from '../order/order.service';
|
||||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||||
import { UserService } from '../user/user.service';
|
import { UserService } from '../user/user.service';
|
||||||
import { DateRange } from './interfaces/date-range.type';
|
|
||||||
import { PortfolioItem } from './interfaces/portfolio-item.interface';
|
|
||||||
import { PortfolioOverview } from './interfaces/portfolio-overview.interface';
|
|
||||||
import {
|
import {
|
||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
PortfolioPositionDetail
|
PortfolioPositionDetail
|
||||||
@ -73,7 +76,8 @@ export class PortfolioService {
|
|||||||
// Get portfolio from database
|
// Get portfolio from database
|
||||||
const orders = await this.orderService.orders({
|
const orders = await this.orderService.orders({
|
||||||
include: {
|
include: {
|
||||||
Account: true
|
Account: true,
|
||||||
|
SymbolProfile: true
|
||||||
},
|
},
|
||||||
orderBy: { date: 'asc' },
|
orderBy: { date: 'asc' },
|
||||||
where: { userId: aUserId }
|
where: { userId: aUserId }
|
||||||
@ -157,8 +161,8 @@ export class PortfolioService {
|
|||||||
return {
|
return {
|
||||||
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
|
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
|
||||||
grossPerformancePercent: portfolioItem.grossPerformancePercent,
|
grossPerformancePercent: portfolioItem.grossPerformancePercent,
|
||||||
marketPrice: portfolioItem.value || null,
|
marketPrice: portfolioItem.value ?? null,
|
||||||
value: portfolioItem.value || null
|
value: portfolioItem.value - portfolioItem.investment ?? null
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -213,6 +217,8 @@ export class PortfolioService {
|
|||||||
transactionCount
|
transactionCount
|
||||||
} = portfolio.getPositions(new Date())[aSymbol];
|
} = portfolio.getPositions(new Date())[aSymbol];
|
||||||
|
|
||||||
|
const orders = portfolio.getOrders(aSymbol);
|
||||||
|
|
||||||
const historicalData = await this.dataProviderService.getHistorical(
|
const historicalData = await this.dataProviderService.getHistorical(
|
||||||
[aSymbol],
|
[aSymbol],
|
||||||
'day',
|
'day',
|
||||||
@ -225,6 +231,7 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const historicalDataArray: HistoricalDataItem[] = [];
|
const historicalDataArray: HistoricalDataItem[] = [];
|
||||||
|
let currentAveragePrice: number;
|
||||||
let maxPrice = marketPrice;
|
let maxPrice = marketPrice;
|
||||||
let minPrice = marketPrice;
|
let minPrice = marketPrice;
|
||||||
|
|
||||||
@ -232,9 +239,24 @@ export class PortfolioService {
|
|||||||
for (const [date, { marketPrice }] of Object.entries(
|
for (const [date, { marketPrice }] of Object.entries(
|
||||||
historicalData[aSymbol]
|
historicalData[aSymbol]
|
||||||
)) {
|
)) {
|
||||||
|
const currentDate = parse(date, 'yyyy-MM-dd', new Date());
|
||||||
|
if (
|
||||||
|
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
|
||||||
|
isAfter(currentDate, parseISO(orders[0]?.getDate()))
|
||||||
|
) {
|
||||||
|
// Get snapshot of first day of month
|
||||||
|
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
|
||||||
|
.positions[aSymbol];
|
||||||
|
orders.shift();
|
||||||
|
|
||||||
|
if (snapshot?.averagePrice) {
|
||||||
|
currentAveragePrice = snapshot?.averagePrice;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
historicalDataArray.push({
|
historicalDataArray.push({
|
||||||
averagePrice,
|
|
||||||
date,
|
date,
|
||||||
|
averagePrice: currentAveragePrice,
|
||||||
value: marketPrice
|
value: marketPrice
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -288,7 +310,7 @@ export class PortfolioService {
|
|||||||
|
|
||||||
if (isEmpty(historicalData)) {
|
if (isEmpty(historicalData)) {
|
||||||
historicalData = await this.dataProviderService.getHistoricalRaw(
|
historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
[aSymbol],
|
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
|
||||||
portfolio.getMinDate(),
|
portfolio.getMinDate(),
|
||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
|
@ -1,4 +1,7 @@
|
|||||||
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
export interface LookupItem {
|
export interface LookupItem {
|
||||||
|
dataSource: DataSource;
|
||||||
name: string;
|
name: string;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
@ -28,9 +28,12 @@ export class SymbolController {
|
|||||||
*/
|
*/
|
||||||
@Get('lookup')
|
@Get('lookup')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async lookupSymbol(@Query() { query }): Promise<LookupItem[]> {
|
public async lookupSymbol(
|
||||||
|
@Query() { query = '' }
|
||||||
|
): Promise<{ items: LookupItem[] }> {
|
||||||
try {
|
try {
|
||||||
return this.symbolService.lookup(query);
|
const encodedQuery = encodeURIComponent(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),
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||||
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 { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency, DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
|
||||||
|
|
||||||
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';
|
||||||
@ -27,62 +25,30 @@ export class SymbolService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async lookup(aQuery = ''): Promise<LookupItem[]> {
|
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
const query = aQuery.toLowerCase();
|
const results: { items: LookupItem[] } = { items: [] };
|
||||||
const results: LookupItem[] = [];
|
|
||||||
|
|
||||||
if (!query) {
|
if (!aQuery) {
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
const get = bent(
|
|
||||||
`https://query1.finance.yahoo.com/v1/finance/search?q=${query}&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',
|
|
||||||
'json',
|
|
||||||
200
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add custom symbols
|
|
||||||
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
|
|
||||||
scraperConfigurations.forEach((scraperConfiguration) => {
|
|
||||||
if (scraperConfiguration.name.toLowerCase().startsWith(query)) {
|
|
||||||
results.push({
|
|
||||||
name: scraperConfiguration.name,
|
|
||||||
symbol: scraperConfiguration.symbol
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { quotes } = await get();
|
const { items } = await this.dataProviderService.search(aQuery);
|
||||||
|
results.items = items;
|
||||||
|
|
||||||
const searchResult = quotes
|
// Add custom symbols
|
||||||
.filter(({ isYahooFinance }) => {
|
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
|
||||||
return isYahooFinance;
|
scraperConfigurations.forEach((scraperConfiguration) => {
|
||||||
})
|
if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) {
|
||||||
.filter(({ quoteType }) => {
|
results.items.push({
|
||||||
return (
|
dataSource: DataSource.GHOSTFOLIO,
|
||||||
quoteType === 'CRYPTOCURRENCY' ||
|
name: scraperConfiguration.name,
|
||||||
quoteType === 'EQUITY' ||
|
symbol: scraperConfiguration.symbol
|
||||||
quoteType === 'ETF'
|
});
|
||||||
);
|
}
|
||||||
})
|
});
|
||||||
.filter(({ quoteType, symbol }) => {
|
|
||||||
if (quoteType === 'CRYPTOCURRENCY') {
|
|
||||||
// Only allow cryptocurrencies in USD
|
|
||||||
return symbol.includes('USD');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return results;
|
||||||
})
|
|
||||||
.map(({ longname, shortname, symbol }) => {
|
|
||||||
return {
|
|
||||||
name: longname || shortname,
|
|
||||||
symbol: convertFromYahooSymbol(symbol)
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return results.concat(searchResult);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
|
@ -1,21 +0,0 @@
|
|||||||
import { Account, Currency } from '@prisma/client';
|
|
||||||
|
|
||||||
import { Access } from './access.interface';
|
|
||||||
|
|
||||||
export interface User {
|
|
||||||
access: Access[];
|
|
||||||
accounts: Account[];
|
|
||||||
alias?: string;
|
|
||||||
id: string;
|
|
||||||
permissions: string[];
|
|
||||||
settings: UserSettings;
|
|
||||||
subscription: {
|
|
||||||
expiresAt: Date;
|
|
||||||
type: 'Trial';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UserSettings {
|
|
||||||
baseCurrency: Currency;
|
|
||||||
locale: string;
|
|
||||||
}
|
|
@ -1,7 +1,10 @@
|
|||||||
import { Currency } from '@prisma/client';
|
import { Currency, ViewMode } from '@prisma/client';
|
||||||
import { IsString } from 'class-validator';
|
import { IsString } from 'class-validator';
|
||||||
|
|
||||||
export class UpdateUserSettingsDto {
|
export class UpdateUserSettingsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
currency: Currency;
|
baseCurrency: Currency;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
viewMode: ViewMode;
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,10 @@
|
|||||||
import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
|
import {
|
||||||
|
getPermissions,
|
||||||
|
hasPermission,
|
||||||
|
permissions
|
||||||
|
} from '@ghostfolio/common/permissions';
|
||||||
|
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
@ -20,7 +25,6 @@ import { User as UserModel } from '@prisma/client';
|
|||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { UserItem } from './interfaces/user-item.interface';
|
import { UserItem } from './interfaces/user-item.interface';
|
||||||
import { User } from './interfaces/user.interface';
|
|
||||||
import { UpdateUserSettingsDto } from './update-user-settings.dto';
|
import { UpdateUserSettingsDto } from './update-user-settings.dto';
|
||||||
import { UserService } from './user.service';
|
import { UserService } from './user.service';
|
||||||
|
|
||||||
@ -89,8 +93,9 @@ export class UserController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return await this.userService.updateUserSettings({
|
return await this.userService.updateUserSettings({
|
||||||
currency: data.currency,
|
currency: data.baseCurrency,
|
||||||
userId: this.request.user.id
|
userId: this.request.user.id,
|
||||||
|
viewMode: data.viewMode
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,17 +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 {
|
import { locale } from '@ghostfolio/common/config';
|
||||||
getPermissions,
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
locale,
|
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||||
permissions,
|
import { getPermissions, permissions } from '@ghostfolio/common/permissions';
|
||||||
resetHours
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
} from '@ghostfolio/helper';
|
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency, Prisma, Provider, User } from '@prisma/client';
|
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||||
import { add } from 'date-fns';
|
import { add, isBefore } from 'date-fns';
|
||||||
|
|
||||||
import { UserWithSettings } from '../interfaces/user-with-settings';
|
|
||||||
import { User as IUser } from './interfaces/user.interface';
|
|
||||||
|
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
@ -29,7 +25,8 @@ export class UserService {
|
|||||||
alias,
|
alias,
|
||||||
id,
|
id,
|
||||||
role,
|
role,
|
||||||
Settings
|
Settings,
|
||||||
|
subscription
|
||||||
}: UserWithSettings): Promise<IUser> {
|
}: UserWithSettings): Promise<IUser> {
|
||||||
const access = await this.prisma.access.findMany({
|
const access = await this.prisma.access.findMany({
|
||||||
include: {
|
include: {
|
||||||
@ -48,6 +45,7 @@ export class UserService {
|
|||||||
return {
|
return {
|
||||||
alias,
|
alias,
|
||||||
id,
|
id,
|
||||||
|
subscription,
|
||||||
access: access.map((accessItem) => {
|
access: access.map((accessItem) => {
|
||||||
return {
|
return {
|
||||||
alias: accessItem.User.alias,
|
alias: accessItem.User.alias,
|
||||||
@ -57,12 +55,9 @@ export class UserService {
|
|||||||
accounts: Account,
|
accounts: Account,
|
||||||
permissions: currentPermissions,
|
permissions: currentPermissions,
|
||||||
settings: {
|
settings: {
|
||||||
baseCurrency: Settings?.currency || UserService.DEFAULT_CURRENCY,
|
locale,
|
||||||
locale
|
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||||
},
|
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
|
||||||
subscription: {
|
|
||||||
expiresAt: resetHours(add(new Date(), { days: 7 })),
|
|
||||||
type: 'Trial'
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -70,25 +65,49 @@ export class UserService {
|
|||||||
public async user(
|
public async user(
|
||||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||||
): Promise<UserWithSettings | null> {
|
): Promise<UserWithSettings | null> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const userFromDatabase = await this.prisma.user.findUnique({
|
||||||
include: { Account: true, Settings: true },
|
include: { Account: true, Settings: true, Subscription: true },
|
||||||
where: userWhereUniqueInput
|
where: userWhereUniqueInput
|
||||||
});
|
});
|
||||||
|
|
||||||
if (user?.Settings) {
|
const user: UserWithSettings = userFromDatabase;
|
||||||
if (!user.Settings.currency) {
|
|
||||||
|
if (userFromDatabase?.Settings) {
|
||||||
|
if (!userFromDatabase.Settings.currency) {
|
||||||
// Set default currency if needed
|
// Set default currency if needed
|
||||||
user.Settings.currency = UserService.DEFAULT_CURRENCY;
|
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
|
||||||
}
|
}
|
||||||
} else if (user) {
|
} else if (userFromDatabase) {
|
||||||
// Set default settings if needed
|
// Set default settings if needed
|
||||||
user.Settings = {
|
userFromDatabase.Settings = {
|
||||||
currency: UserService.DEFAULT_CURRENCY,
|
currency: UserService.DEFAULT_CURRENCY,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
userId: user?.id
|
userId: userFromDatabase?.id,
|
||||||
|
viewMode: ViewMode.DEFAULT
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||||
|
if (userFromDatabase?.Subscription?.length > 0) {
|
||||||
|
const latestSubscription = userFromDatabase.Subscription.reduce(
|
||||||
|
(a, b) => {
|
||||||
|
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
user.subscription = {
|
||||||
|
expiresAt: latestSubscription.expiresAt,
|
||||||
|
type: isBefore(new Date(), latestSubscription.expiresAt)
|
||||||
|
? SubscriptionType.Premium
|
||||||
|
: SubscriptionType.Basic
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
user.subscription = {
|
||||||
|
type: SubscriptionType.Basic
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,10 +211,12 @@ export class UserService {
|
|||||||
|
|
||||||
public async updateUserSettings({
|
public async updateUserSettings({
|
||||||
currency,
|
currency,
|
||||||
userId
|
userId,
|
||||||
|
viewMode
|
||||||
}: {
|
}: {
|
||||||
currency: Currency;
|
currency?: Currency;
|
||||||
userId: string;
|
userId: string;
|
||||||
|
viewMode?: ViewMode;
|
||||||
}) {
|
}) {
|
||||||
await this.prisma.settings.upsert({
|
await this.prisma.settings.upsert({
|
||||||
create: {
|
create: {
|
||||||
@ -204,10 +225,12 @@ export class UserService {
|
|||||||
connect: {
|
connect: {
|
||||||
id: userId
|
id: userId
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
viewMode
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
currency
|
currency,
|
||||||
|
viewMode
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
userId: userId
|
userId: userId
|
||||||
|
@ -1,7 +1,4 @@
|
|||||||
import {
|
import { PortfolioItem, Position } from '@ghostfolio/common/interfaces';
|
||||||
PortfolioItem,
|
|
||||||
Position
|
|
||||||
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
|
|
||||||
|
|
||||||
import { Order } from '../order';
|
import { Order } from '../order';
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { EvaluationResult } from './evaluation-result.interface';
|
import { EvaluationResult } from './evaluation-result.interface';
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { Account, Currency, Platform } from '@prisma/client';
|
import { Account, Currency, Platform, SymbolProfile } 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';
|
||||||
@ -12,6 +12,7 @@ export class Order {
|
|||||||
private id: string;
|
private id: string;
|
||||||
private quantity: number;
|
private quantity: number;
|
||||||
private symbol: string;
|
private symbol: string;
|
||||||
|
private symbolProfile: SymbolProfile;
|
||||||
private total: number;
|
private total: number;
|
||||||
private type: OrderType;
|
private type: OrderType;
|
||||||
private unitPrice: number;
|
private unitPrice: number;
|
||||||
@ -24,6 +25,7 @@ export class Order {
|
|||||||
this.id = data.id || uuidv4();
|
this.id = data.id || uuidv4();
|
||||||
this.quantity = data.quantity;
|
this.quantity = data.quantity;
|
||||||
this.symbol = data.symbol;
|
this.symbol = data.symbol;
|
||||||
|
this.symbolProfile = data.symbolProfile;
|
||||||
this.type = data.type;
|
this.type = data.type;
|
||||||
this.unitPrice = data.unitPrice;
|
this.unitPrice = data.unitPrice;
|
||||||
|
|
||||||
@ -58,6 +60,10 @@ export class Order {
|
|||||||
return this.symbol;
|
return this.symbol;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSymbolProfile() {
|
||||||
|
return this.symbolProfile;
|
||||||
|
}
|
||||||
|
|
||||||
public getTotal() {
|
public getTotal() {
|
||||||
return this.total;
|
return this.total;
|
||||||
}
|
}
|
||||||
|
@ -1,65 +1,102 @@
|
|||||||
import { baseCurrency, getUtc, getYesterday } from '@ghostfolio/helper';
|
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
|
||||||
import { Test } from '@nestjs/testing';
|
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
|
||||||
import { AccountType, Currency, DataSource, Role, Type } from '@prisma/client';
|
import {
|
||||||
|
AccountType,
|
||||||
|
Currency,
|
||||||
|
DataSource,
|
||||||
|
Role,
|
||||||
|
Type,
|
||||||
|
ViewMode
|
||||||
|
} from '@prisma/client';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import { ConfigurationService } from '../services/configuration.service';
|
|
||||||
import { DataProviderService } from '../services/data-provider.service';
|
import { DataProviderService } from '../services/data-provider.service';
|
||||||
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
|
|
||||||
import { GhostfolioScraperApiService } from '../services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
|
||||||
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
|
||||||
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
|
|
||||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||||
import { MarketState } from '../services/interfaces/interfaces';
|
import { MarketState } from '../services/interfaces/interfaces';
|
||||||
import { PrismaService } from '../services/prisma.service';
|
|
||||||
import { RulesService } from '../services/rules.service';
|
import { RulesService } from '../services/rules.service';
|
||||||
import { Portfolio } from './portfolio';
|
import { Portfolio } from './portfolio';
|
||||||
|
|
||||||
|
jest.mock('../services/data-provider.service', () => {
|
||||||
|
return {
|
||||||
|
DataProviderService: jest.fn().mockImplementation(() => {
|
||||||
|
const today = format(new Date(), 'yyyy-MM-dd');
|
||||||
|
const yesterday = format(getYesterday(), 'yyyy-MM-dd');
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: () => {
|
||||||
|
return Promise.resolve({
|
||||||
|
BTCUSD: {
|
||||||
|
currency: Currency.USD,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
exchange: UNKNOWN_KEY,
|
||||||
|
marketPrice: 57973.008,
|
||||||
|
marketState: MarketState.open,
|
||||||
|
name: 'Bitcoin USD',
|
||||||
|
type: 'Cryptocurrency'
|
||||||
|
},
|
||||||
|
ETHUSD: {
|
||||||
|
currency: Currency.USD,
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
exchange: UNKNOWN_KEY,
|
||||||
|
marketPrice: 3915.337,
|
||||||
|
marketState: MarketState.open,
|
||||||
|
name: 'Ethereum USD',
|
||||||
|
type: 'Cryptocurrency'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
getHistorical: () => {
|
||||||
|
return Promise.resolve({
|
||||||
|
BTCUSD: {
|
||||||
|
[yesterday]: 56710.122,
|
||||||
|
[today]: 57973.008
|
||||||
|
},
|
||||||
|
ETHUSD: {
|
||||||
|
[yesterday]: 3641.984,
|
||||||
|
[today]: 3915.337
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../services/exchange-rate-data.service', () => {
|
||||||
|
return {
|
||||||
|
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
initialize: () => Promise.resolve(),
|
||||||
|
toCurrency: (value: number) => value
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock('../services/data-provider.service');
|
||||||
|
jest.mock('../services/exchange-rate-data.service');
|
||||||
|
jest.mock('../services/rules.service');
|
||||||
|
|
||||||
const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
|
const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
|
||||||
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
|
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
|
||||||
|
|
||||||
describe('Portfolio', () => {
|
describe('Portfolio', () => {
|
||||||
let alphaVantageService: AlphaVantageService;
|
|
||||||
let configurationService: ConfigurationService;
|
|
||||||
let dataProviderService: DataProviderService;
|
let dataProviderService: DataProviderService;
|
||||||
let exchangeRateDataService: ExchangeRateDataService;
|
let exchangeRateDataService: ExchangeRateDataService;
|
||||||
let ghostfolioScraperApiService: GhostfolioScraperApiService;
|
|
||||||
let portfolio: Portfolio;
|
let portfolio: Portfolio;
|
||||||
let prismaService: PrismaService;
|
|
||||||
let rakutenRapidApiService: RakutenRapidApiService;
|
|
||||||
let rulesService: RulesService;
|
let rulesService: RulesService;
|
||||||
let yahooFinanceService: YahooFinanceService;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const app = await Test.createTestingModule({
|
dataProviderService = new DataProviderService(
|
||||||
imports: [],
|
null,
|
||||||
providers: [
|
null,
|
||||||
AlphaVantageService,
|
null,
|
||||||
ConfigurationService,
|
null,
|
||||||
DataProviderService,
|
null,
|
||||||
ExchangeRateDataService,
|
null
|
||||||
GhostfolioScraperApiService,
|
|
||||||
PrismaService,
|
|
||||||
RakutenRapidApiService,
|
|
||||||
RulesService,
|
|
||||||
YahooFinanceService
|
|
||||||
]
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
alphaVantageService = app.get<AlphaVantageService>(AlphaVantageService);
|
|
||||||
configurationService = app.get<ConfigurationService>(ConfigurationService);
|
|
||||||
dataProviderService = app.get<DataProviderService>(DataProviderService);
|
|
||||||
exchangeRateDataService = app.get<ExchangeRateDataService>(
|
|
||||||
ExchangeRateDataService
|
|
||||||
);
|
);
|
||||||
ghostfolioScraperApiService = app.get<GhostfolioScraperApiService>(
|
exchangeRateDataService = new ExchangeRateDataService(null);
|
||||||
GhostfolioScraperApiService
|
rulesService = new RulesService();
|
||||||
);
|
|
||||||
prismaService = app.get<PrismaService>(PrismaService);
|
|
||||||
rakutenRapidApiService = app.get<RakutenRapidApiService>(
|
|
||||||
RakutenRapidApiService
|
|
||||||
);
|
|
||||||
rulesService = app.get<RulesService>(RulesService);
|
|
||||||
yahooFinanceService = app.get<YahooFinanceService>(YahooFinanceService);
|
|
||||||
|
|
||||||
await exchangeRateDataService.initialize();
|
await exchangeRateDataService.initialize();
|
||||||
|
|
||||||
@ -90,7 +127,8 @@ describe('Portfolio', () => {
|
|||||||
Settings: {
|
Settings: {
|
||||||
currency: Currency.CHF,
|
currency: Currency.CHF,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
userId: USER_ID
|
userId: USER_ID,
|
||||||
|
viewMode: ViewMode.DEFAULT
|
||||||
},
|
},
|
||||||
thirdPartyId: null,
|
thirdPartyId: null,
|
||||||
updatedAt: new Date()
|
updatedAt: new Date()
|
||||||
@ -151,6 +189,7 @@ describe('Portfolio', () => {
|
|||||||
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
|
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
symbol: 'BTCUSD',
|
symbol: 'BTCUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 49631.24,
|
unitPrice: 49631.24,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -170,7 +209,7 @@ describe('Portfolio', () => {
|
|||||||
expect(details).toMatchObject({
|
expect(details).toMatchObject({
|
||||||
BTCUSD: {
|
BTCUSD: {
|
||||||
accounts: {
|
accounts: {
|
||||||
Other: {
|
[UNKNOWN_KEY]: {
|
||||||
/*current: exchangeRateDataService.toCurrency(
|
/*current: exchangeRateDataService.toCurrency(
|
||||||
1 * 49631.24,
|
1 * 49631.24,
|
||||||
Currency.USD,
|
Currency.USD,
|
||||||
@ -183,8 +222,11 @@ describe('Portfolio', () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
allocationCurrent: 1,
|
||||||
|
allocationInvestment: 1,
|
||||||
|
countries: [],
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
exchange: 'Other',
|
exchange: UNKNOWN_KEY,
|
||||||
grossPerformance: 0,
|
grossPerformance: 0,
|
||||||
grossPerformancePercent: 0,
|
grossPerformancePercent: 0,
|
||||||
investment: exchangeRateDataService.toCurrency(
|
investment: exchangeRateDataService.toCurrency(
|
||||||
@ -192,12 +234,10 @@ describe('Portfolio', () => {
|
|||||||
Currency.USD,
|
Currency.USD,
|
||||||
baseCurrency
|
baseCurrency
|
||||||
),
|
),
|
||||||
// marketPrice: 57973.008,
|
marketPrice: 57973.008,
|
||||||
marketState: MarketState.open,
|
marketState: MarketState.open,
|
||||||
name: 'Bitcoin USD',
|
name: 'Bitcoin USD',
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
// shareCurrent: 0.9999999559148652,
|
|
||||||
shareInvestment: 1,
|
|
||||||
symbol: 'BTCUSD',
|
symbol: 'BTCUSD',
|
||||||
transactionCount: 1,
|
transactionCount: 1,
|
||||||
type: 'Cryptocurrency'
|
type: 'Cryptocurrency'
|
||||||
@ -252,6 +292,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||||
quantity: 0.2,
|
quantity: 0.2,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 991.49,
|
unitPrice: 991.49,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -271,7 +312,7 @@ describe('Portfolio', () => {
|
|||||||
expect(details).toMatchObject({
|
expect(details).toMatchObject({
|
||||||
ETHUSD: {
|
ETHUSD: {
|
||||||
accounts: {
|
accounts: {
|
||||||
Other: {
|
[UNKNOWN_KEY]: {
|
||||||
/*current: exchangeRateDataService.toCurrency(
|
/*current: exchangeRateDataService.toCurrency(
|
||||||
0.2 * 991.49,
|
0.2 * 991.49,
|
||||||
Currency.USD,
|
Currency.USD,
|
||||||
@ -284,8 +325,11 @@ describe('Portfolio', () => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// allocationCurrent: 1,
|
||||||
|
allocationInvestment: 1,
|
||||||
|
countries: [],
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
exchange: 'Other',
|
exchange: UNKNOWN_KEY,
|
||||||
// grossPerformance: 0,
|
// grossPerformance: 0,
|
||||||
// grossPerformancePercent: 0,
|
// grossPerformancePercent: 0,
|
||||||
investment: exchangeRateDataService.toCurrency(
|
investment: exchangeRateDataService.toCurrency(
|
||||||
@ -293,11 +337,9 @@ describe('Portfolio', () => {
|
|||||||
Currency.USD,
|
Currency.USD,
|
||||||
baseCurrency
|
baseCurrency
|
||||||
),
|
),
|
||||||
// marketPrice: 57973.008,
|
marketPrice: 3915.337,
|
||||||
name: 'Ethereum USD',
|
name: 'Ethereum USD',
|
||||||
quantity: 0.2,
|
quantity: 0.2,
|
||||||
// shareCurrent: 1,
|
|
||||||
shareInvestment: 1,
|
|
||||||
transactionCount: 1,
|
transactionCount: 1,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
type: 'Cryptocurrency'
|
type: 'Cryptocurrency'
|
||||||
@ -326,7 +368,7 @@ describe('Portfolio', () => {
|
|||||||
baseCurrency
|
baseCurrency
|
||||||
),
|
),
|
||||||
investmentInOriginalCurrency: 0.2 * 991.49,
|
investmentInOriginalCurrency: 0.2 * 991.49,
|
||||||
// marketPrice: 0,
|
// marketPrice: 3915.337,
|
||||||
quantity: 0.2
|
quantity: 0.2
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -347,6 +389,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||||
quantity: 0.2,
|
quantity: 0.2,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 991.49,
|
unitPrice: 991.49,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -363,6 +406,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||||
quantity: 0.3,
|
quantity: 0.3,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 1050,
|
unitPrice: 1050,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -402,7 +446,7 @@ describe('Portfolio', () => {
|
|||||||
baseCurrency
|
baseCurrency
|
||||||
),
|
),
|
||||||
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
|
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
|
||||||
// marketPrice: 0,
|
// marketPrice: 3641.984,
|
||||||
quantity: 0.5
|
quantity: 0.5
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -423,6 +467,7 @@ describe('Portfolio', () => {
|
|||||||
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
|
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
|
||||||
quantity: 0.05614682,
|
quantity: 0.05614682,
|
||||||
symbol: 'BTCUSD',
|
symbol: 'BTCUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 3562.089535970158,
|
unitPrice: 3562.089535970158,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -439,6 +484,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||||
quantity: 0.2,
|
quantity: 0.2,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 991.49,
|
unitPrice: 991.49,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -512,6 +558,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
|
||||||
quantity: 0.2,
|
quantity: 0.2,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 991.49,
|
unitPrice: 991.49,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -528,6 +575,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||||
quantity: 0.1,
|
quantity: 0.1,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.SELL,
|
type: Type.SELL,
|
||||||
unitPrice: 1050,
|
unitPrice: 1050,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -544,6 +592,7 @@ describe('Portfolio', () => {
|
|||||||
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
|
||||||
quantity: 0.2,
|
quantity: 0.2,
|
||||||
symbol: 'ETHUSD',
|
symbol: 'ETHUSD',
|
||||||
|
symbolProfileId: null,
|
||||||
type: Type.BUY,
|
type: Type.BUY,
|
||||||
unitPrice: 1050,
|
unitPrice: 1050,
|
||||||
updatedAt: null,
|
updatedAt: null,
|
||||||
@ -551,8 +600,7 @@ describe('Portfolio', () => {
|
|||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// TODO: Fix
|
expect(portfolio.getCommittedFunds()).toEqual(
|
||||||
/*expect(portfolio.getCommittedFunds()).toEqual(
|
|
||||||
exchangeRateDataService.toCurrency(
|
exchangeRateDataService.toCurrency(
|
||||||
0.2 * 991.49,
|
0.2 * 991.49,
|
||||||
Currency.USD,
|
Currency.USD,
|
||||||
@ -568,7 +616,7 @@ describe('Portfolio', () => {
|
|||||||
Currency.USD,
|
Currency.USD,
|
||||||
baseCurrency
|
baseCurrency
|
||||||
)
|
)
|
||||||
);*/
|
);
|
||||||
|
|
||||||
expect(portfolio.getFees()).toEqual(
|
expect(portfolio.getFees()).toEqual(
|
||||||
exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency)
|
exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency)
|
||||||
@ -580,12 +628,11 @@ describe('Portfolio', () => {
|
|||||||
(0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2),
|
(0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2),
|
||||||
currency: Currency.USD,
|
currency: Currency.USD,
|
||||||
firstBuyDate: '2018-01-05T00:00:00.000Z',
|
firstBuyDate: '2018-01-05T00:00:00.000Z',
|
||||||
// TODO: Fix
|
investment: exchangeRateDataService.toCurrency(
|
||||||
/*investment: exchangeRateDataService.toCurrency(
|
|
||||||
0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
|
0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
|
||||||
Currency.USD,
|
Currency.USD,
|
||||||
baseCurrency
|
baseCurrency
|
||||||
),*/
|
),
|
||||||
investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
|
investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
|
||||||
// marketPrice: 0,
|
// marketPrice: 0,
|
||||||
quantity: 0.2 - 0.1 + 0.2
|
quantity: 0.2 - 0.1 + 0.2
|
||||||
@ -595,8 +642,4 @@ describe('Portfolio', () => {
|
|||||||
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
|
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
prismaService.$disconnect();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -1,8 +1,17 @@
|
|||||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
|
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
PortfolioItem,
|
PortfolioItem,
|
||||||
Position
|
PortfolioPerformance,
|
||||||
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
|
PortfolioPosition,
|
||||||
import { getToday, getYesterday, resetHours } from '@ghostfolio/helper';
|
PortfolioReport,
|
||||||
|
Position,
|
||||||
|
UserWithSettings
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
|
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { continents, countries } from 'countries-list';
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
format,
|
format,
|
||||||
@ -22,18 +31,13 @@ import {
|
|||||||
import { cloneDeep, isEmpty } from 'lodash';
|
import { cloneDeep, isEmpty } from 'lodash';
|
||||||
import * as roundTo from 'round-to';
|
import * as roundTo from 'round-to';
|
||||||
|
|
||||||
import { UserWithSettings } from '../app/interfaces/user-with-settings';
|
|
||||||
import { OrderWithAccount } from '../app/order/interfaces/order-with-account.type';
|
|
||||||
import { DateRange } from '../app/portfolio/interfaces/date-range.type';
|
|
||||||
import { PortfolioPerformance } from '../app/portfolio/interfaces/portfolio-performance.interface';
|
|
||||||
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
|
|
||||||
import { PortfolioReport } from '../app/portfolio/interfaces/portfolio-report.interface';
|
|
||||||
import { DataProviderService } from '../services/data-provider.service';
|
import { DataProviderService } from '../services/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||||
import { IOrder } from '../services/interfaces/interfaces';
|
import { IOrder } from '../services/interfaces/interfaces';
|
||||||
import { RulesService } from '../services/rules.service';
|
import { RulesService } from '../services/rules.service';
|
||||||
import { PortfolioInterface } from './interfaces/portfolio.interface';
|
import { PortfolioInterface } from './interfaces/portfolio.interface';
|
||||||
import { Order } from './order';
|
import { Order } from './order';
|
||||||
|
import { OrderType } from './order-type';
|
||||||
import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment';
|
import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment';
|
||||||
import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
|
import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
|
||||||
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
|
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
|
||||||
@ -126,6 +130,7 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
id,
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
symbol,
|
||||||
|
symbolProfile,
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
}) => {
|
}) => {
|
||||||
@ -138,6 +143,7 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
id,
|
id,
|
||||||
quantity,
|
quantity,
|
||||||
symbol,
|
symbol,
|
||||||
|
symbolProfile,
|
||||||
type,
|
type,
|
||||||
unitPrice
|
unitPrice
|
||||||
})
|
})
|
||||||
@ -203,6 +209,7 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
|
|
||||||
symbols.forEach((symbol) => {
|
symbols.forEach((symbol) => {
|
||||||
const accounts: PortfolioPosition['accounts'] = {};
|
const accounts: PortfolioPosition['accounts'] = {};
|
||||||
|
let countriesOfSymbol: Country[];
|
||||||
const [portfolioItem] = portfolioItems;
|
const [portfolioItem] = portfolioItems;
|
||||||
|
|
||||||
const ordersBySymbol = this.getOrders().filter((order) => {
|
const ordersBySymbol = this.getOrders().filter((order) => {
|
||||||
@ -227,19 +234,36 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
originalValueOfSymbol *= -1;
|
originalValueOfSymbol *= -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accounts[orderOfSymbol.getAccount()?.name || 'Other']?.current) {
|
if (
|
||||||
|
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
|
||||||
|
) {
|
||||||
accounts[
|
accounts[
|
||||||
orderOfSymbol.getAccount()?.name || 'Other'
|
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
|
||||||
].current += currentValueOfSymbol;
|
].current += currentValueOfSymbol;
|
||||||
accounts[
|
accounts[
|
||||||
orderOfSymbol.getAccount()?.name || 'Other'
|
orderOfSymbol.getAccount()?.name || UNKNOWN_KEY
|
||||||
].original += originalValueOfSymbol;
|
].original += originalValueOfSymbol;
|
||||||
} else {
|
} else {
|
||||||
accounts[orderOfSymbol.getAccount()?.name || 'Other'] = {
|
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
|
||||||
current: currentValueOfSymbol,
|
current: currentValueOfSymbol,
|
||||||
original: originalValueOfSymbol
|
original: originalValueOfSymbol
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
countriesOfSymbol = (
|
||||||
|
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
|
||||||
|
[]
|
||||||
|
).map((country) => {
|
||||||
|
const { code, weight } = country as Prisma.JsonObject;
|
||||||
|
|
||||||
|
return {
|
||||||
|
code: code as string,
|
||||||
|
continent:
|
||||||
|
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
||||||
|
name: countries[code as string]?.name ?? UNKNOWN_KEY,
|
||||||
|
weight: weight as number
|
||||||
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
let now = portfolioItemsNow.positions[symbol].marketPrice;
|
let now = portfolioItemsNow.positions[symbol].marketPrice;
|
||||||
@ -278,6 +302,15 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
...data[symbol],
|
...data[symbol],
|
||||||
accounts,
|
accounts,
|
||||||
symbol,
|
symbol,
|
||||||
|
allocationCurrent:
|
||||||
|
this.exchangeRateDataService.toCurrency(
|
||||||
|
portfolioItem.positions[symbol].quantity * now,
|
||||||
|
data[symbol]?.currency,
|
||||||
|
this.user.Settings.currency
|
||||||
|
) / value,
|
||||||
|
allocationInvestment:
|
||||||
|
portfolioItem.positions[symbol].investment / investment,
|
||||||
|
countries: countriesOfSymbol,
|
||||||
grossPerformance: roundTo(
|
grossPerformance: roundTo(
|
||||||
portfolioItemsNow.positions[symbol].quantity * (now - before),
|
portfolioItemsNow.positions[symbol].quantity * (now - before),
|
||||||
2
|
2
|
||||||
@ -285,15 +318,12 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
grossPerformancePercent: roundTo((now - before) / before, 4),
|
grossPerformancePercent: roundTo((now - before) / before, 4),
|
||||||
investment: portfolioItem.positions[symbol].investment,
|
investment: portfolioItem.positions[symbol].investment,
|
||||||
quantity: portfolioItem.positions[symbol].quantity,
|
quantity: portfolioItem.positions[symbol].quantity,
|
||||||
shareCurrent:
|
transactionCount: portfolioItem.positions[symbol].transactionCount,
|
||||||
this.exchangeRateDataService.toCurrency(
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
portfolioItem.positions[symbol].quantity * now,
|
portfolioItem.positions[symbol].quantity * now,
|
||||||
data[symbol]?.currency,
|
data[symbol]?.currency,
|
||||||
this.user.Settings.currency
|
this.user.Settings.currency
|
||||||
) / value,
|
)
|
||||||
shareInvestment:
|
|
||||||
portfolioItem.positions[symbol].investment / investment,
|
|
||||||
transactionCount: portfolioItem.positions[symbol].transactionCount
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -399,10 +429,10 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
accountClusterRisk: await this.rulesService.evaluate(
|
accountClusterRisk: await this.rulesService.evaluate(
|
||||||
this,
|
this,
|
||||||
[
|
[
|
||||||
new AccountClusterRiskCurrentInvestment(
|
new AccountClusterRiskInitialInvestment(
|
||||||
this.exchangeRateDataService
|
this.exchangeRateDataService
|
||||||
),
|
),
|
||||||
new AccountClusterRiskInitialInvestment(
|
new AccountClusterRiskCurrentInvestment(
|
||||||
this.exchangeRateDataService
|
this.exchangeRateDataService
|
||||||
),
|
),
|
||||||
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
|
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
|
||||||
@ -483,7 +513,13 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
.reduce((previous, current) => previous + current, 0);
|
.reduce((previous, current) => previous + current, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getOrders() {
|
public getOrders(aSymbol?: string) {
|
||||||
|
if (aSymbol) {
|
||||||
|
return this.orders.filter((order) => {
|
||||||
|
return order.getSymbol() === aSymbol;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return this.orders;
|
return this.orders;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -530,12 +566,13 @@ export class Portfolio implements PortfolioInterface {
|
|||||||
this.orders.push(
|
this.orders.push(
|
||||||
new Order({
|
new Order({
|
||||||
account: order.Account,
|
account: order.Account,
|
||||||
currency: <any>order.currency,
|
currency: order.currency,
|
||||||
date: order.date.toISOString(),
|
date: order.date.toISOString(),
|
||||||
fee: order.fee,
|
fee: order.fee,
|
||||||
quantity: order.quantity,
|
quantity: order.quantity,
|
||||||
symbol: order.symbol,
|
symbol: order.symbol,
|
||||||
type: <any>order.type,
|
symbolProfile: order.SymbolProfile,
|
||||||
|
type: <OrderType>order.type,
|
||||||
unitPrice: order.unitPrice
|
unitPrice: order.unitPrice
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { groupBy } from '@ghostfolio/helper';
|
import { groupBy } from '@ghostfolio/common/helper';
|
||||||
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
import { PortfolioPosition } from '../app/portfolio/interfaces/portfolio-position.interface';
|
|
||||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||||
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
||||||
import { RuleInterface } from './interfaces/rule.interface';
|
import { RuleInterface } from './interfaces/rule.interface';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||||
|
|
||||||
import { Rule } from '../../rule';
|
import { Rule } from '../../rule';
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { bool, cleanEnv, num, port, str } from 'envalid';
|
import { DataSource } from '@prisma/client';
|
||||||
|
import { bool, cleanEnv, json, num, port, str } from 'envalid';
|
||||||
|
|
||||||
import { Environment } from './interfaces/environment.interface';
|
import { Environment } from './interfaces/environment.interface';
|
||||||
|
|
||||||
@ -12,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_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
|
||||||
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
|
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
benchmarks,
|
|
||||||
currencyPairs,
|
|
||||||
getUtc,
|
getUtc,
|
||||||
isGhostfolioScraperApiSymbol,
|
isGhostfolioScraperApiSymbol,
|
||||||
resetHours
|
resetHours
|
||||||
} from '@ghostfolio/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
differenceInHours,
|
differenceInHours,
|
||||||
format,
|
format,
|
||||||
@ -19,6 +19,7 @@ import {
|
|||||||
import { ConfigurationService } from './configuration.service';
|
import { ConfigurationService } from './configuration.service';
|
||||||
import { DataProviderService } from './data-provider.service';
|
import { DataProviderService } from './data-provider.service';
|
||||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||||
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
import { PrismaService } from './prisma.service';
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -116,15 +117,13 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbols(
|
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||||
aSymbolsWithStartDate: { date: Date; symbol: string }[]
|
|
||||||
) {
|
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
|
||||||
for (const { date, symbol } of aSymbolsWithStartDate) {
|
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||||
try {
|
try {
|
||||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
[symbol],
|
[{ dataSource, symbol }],
|
||||||
date,
|
date,
|
||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
@ -185,20 +184,24 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCustomSymbolsToGather(startDate?: Date) {
|
public async getCustomSymbolsToGather(
|
||||||
|
startDate?: Date
|
||||||
|
): Promise<IDataGatheringItem[]> {
|
||||||
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
|
const scraperConfigurations = await this.ghostfolioScraperApi.getScraperConfigurations();
|
||||||
|
|
||||||
return scraperConfigurations.map((scraperConfiguration) => {
|
return scraperConfigurations.map((scraperConfiguration) => {
|
||||||
return {
|
return {
|
||||||
|
dataSource: DataSource.GHOSTFOLIO,
|
||||||
date: startDate,
|
date: startDate,
|
||||||
symbol: scraperConfiguration.symbol
|
symbol: scraperConfiguration.symbol
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private getBenchmarksToGather(startDate: Date) {
|
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
|
||||||
const benchmarksToGather = benchmarks.map((symbol) => {
|
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
|
||||||
return {
|
return {
|
||||||
|
dataSource,
|
||||||
symbol,
|
symbol,
|
||||||
date: startDate
|
date: startDate
|
||||||
};
|
};
|
||||||
@ -206,6 +209,7 @@ export class DataGatheringService {
|
|||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
benchmarksToGather.push({
|
benchmarksToGather.push({
|
||||||
|
dataSource: DataSource.RAKUTEN,
|
||||||
date: startDate,
|
date: startDate,
|
||||||
symbol: 'GF.FEAR_AND_GREED_INDEX'
|
symbol: 'GF.FEAR_AND_GREED_INDEX'
|
||||||
});
|
});
|
||||||
@ -214,16 +218,16 @@ export class DataGatheringService {
|
|||||||
return benchmarksToGather;
|
return benchmarksToGather;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSymbols7D(): Promise<{ date: Date; symbol: string }[]> {
|
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||||
const startDate = subDays(resetHours(new Date()), 7);
|
const startDate = subDays(resetHours(new Date()), 7);
|
||||||
|
|
||||||
const distinctOrders = await this.prisma.order.findMany({
|
const distinctOrders = await this.prisma.order.findMany({
|
||||||
distinct: ['symbol'],
|
distinct: ['symbol'],
|
||||||
orderBy: [{ symbol: 'asc' }],
|
orderBy: [{ symbol: 'asc' }],
|
||||||
select: { symbol: true }
|
select: { dataSource: true, symbol: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
const distinctOrdersWithDate = distinctOrders
|
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
|
||||||
.filter((distinctOrder) => {
|
.filter((distinctOrder) => {
|
||||||
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
|
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
|
||||||
})
|
})
|
||||||
@ -234,12 +238,15 @@ export class DataGatheringService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const currencyPairsToGather = currencyPairs.map((symbol) => {
|
const currencyPairsToGather = currencyPairs.map(
|
||||||
return {
|
({ dataSource, symbol }) => {
|
||||||
symbol,
|
return {
|
||||||
date: startDate
|
dataSource,
|
||||||
};
|
symbol,
|
||||||
});
|
date: startDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
||||||
startDate
|
startDate
|
||||||
@ -253,24 +260,27 @@ export class DataGatheringService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSymbolsMax() {
|
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||||
const startDate = new Date(getUtc('2015-01-01'));
|
const startDate = new Date(getUtc('2015-01-01'));
|
||||||
|
|
||||||
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
||||||
startDate
|
startDate
|
||||||
);
|
);
|
||||||
|
|
||||||
const currencyPairsToGather = currencyPairs.map((symbol) => {
|
const currencyPairsToGather = currencyPairs.map(
|
||||||
return {
|
({ dataSource, symbol }) => {
|
||||||
symbol,
|
return {
|
||||||
date: startDate
|
dataSource,
|
||||||
};
|
symbol,
|
||||||
});
|
date: startDate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const distinctOrders = await this.prisma.order.findMany({
|
const distinctOrders = await this.prisma.order.findMany({
|
||||||
distinct: ['symbol'],
|
distinct: ['symbol'],
|
||||||
orderBy: [{ date: 'asc' }],
|
orderBy: [{ date: 'asc' }],
|
||||||
select: { date: true, symbol: true }
|
select: { dataSource: true, date: true, symbol: true }
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
isCrypto,
|
|
||||||
isGhostfolioScraperApiSymbol,
|
isGhostfolioScraperApiSymbol,
|
||||||
isRakutenRapidApiSymbol
|
isRakutenRapidApiSymbol
|
||||||
} from '@ghostfolio/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import { ConfigurationService } from './configuration.service';
|
import { ConfigurationService } from './configuration.service';
|
||||||
@ -12,16 +12,15 @@ import { AlphaVantageService } from './data-provider/alpha-vantage/alpha-vantage
|
|||||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||||
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||||
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
|
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
|
||||||
import { DataProviderInterface } from './interfaces/data-provider.interface';
|
|
||||||
import { Granularity } from './interfaces/granularity.type';
|
|
||||||
import {
|
import {
|
||||||
|
IDataGatheringItem,
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from './interfaces/interfaces';
|
} from './interfaces/interfaces';
|
||||||
import { PrismaService } from './prisma.service';
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataProviderService implements DataProviderInterface {
|
export class DataProviderService {
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly alphaVantageService: AlphaVantageService,
|
private readonly alphaVantageService: AlphaVantageService,
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
@ -120,68 +119,57 @@ export class DataProviderService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getHistoricalRaw(
|
public async getHistoricalRaw(
|
||||||
aSymbols: string[],
|
aDataGatheringItems: IDataGatheringItem[],
|
||||||
from: Date,
|
from: Date,
|
||||||
to: Date
|
to: Date
|
||||||
): Promise<{
|
): Promise<{
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}> {
|
}> {
|
||||||
const filteredSymbols = aSymbols.filter((symbol) => {
|
const result: {
|
||||||
return !isGhostfolioScraperApiSymbol(symbol);
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
});
|
} = {};
|
||||||
|
|
||||||
const dataOfYahoo = await this.yahooFinanceService.getHistorical(
|
const promises: Promise<{
|
||||||
filteredSymbols,
|
data: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
undefined,
|
symbol: string;
|
||||||
from,
|
}>[] = [];
|
||||||
to
|
for (const { dataSource, symbol } of aDataGatheringItems) {
|
||||||
);
|
const dataProvider = this.getDataProvider(dataSource);
|
||||||
|
if (dataProvider.canHandle(symbol)) {
|
||||||
if (aSymbols.length === 1) {
|
promises.push(
|
||||||
const symbol = aSymbols[0];
|
dataProvider
|
||||||
|
.getHistorical([symbol], undefined, from, to)
|
||||||
if (
|
.then((data) => ({ data: data?.[symbol], symbol }))
|
||||||
isCrypto(symbol) &&
|
|
||||||
this.configurationService.get('ALPHA_VANTAGE_API_KEY')
|
|
||||||
) {
|
|
||||||
// Merge data from Yahoo with data from Alpha Vantage
|
|
||||||
const dataOfAlphaVantage = await this.alphaVantageService.getHistorical(
|
|
||||||
[symbol],
|
|
||||||
undefined,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
|
||||||
[symbol]: {
|
|
||||||
...dataOfYahoo[symbol],
|
|
||||||
...dataOfAlphaVantage[symbol]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
} else if (isGhostfolioScraperApiSymbol(symbol)) {
|
|
||||||
const dataOfGhostfolioScraperApi = await this.ghostfolioScraperApiService.getHistorical(
|
|
||||||
[symbol],
|
|
||||||
undefined,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
|
|
||||||
return dataOfGhostfolioScraperApi;
|
|
||||||
} else if (
|
|
||||||
isRakutenRapidApiSymbol(symbol) &&
|
|
||||||
this.configurationService.get('RAKUTEN_RAPID_API_KEY')
|
|
||||||
) {
|
|
||||||
const dataOfRakutenRapidApi = await this.rakutenRapidApiService.getHistorical(
|
|
||||||
[symbol],
|
|
||||||
undefined,
|
|
||||||
from,
|
|
||||||
to
|
|
||||||
);
|
|
||||||
|
|
||||||
return dataOfRakutenRapidApi;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return dataOfYahoo;
|
const allData = await Promise.all(promises);
|
||||||
|
for (const { data, symbol } of allData) {
|
||||||
|
result[symbol] = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async search(aSymbol: string) {
|
||||||
|
return this.getDataProvider(
|
||||||
|
<DataSource>this.configurationService.get('DATA_SOURCES')[0]
|
||||||
|
).search(aSymbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDataProvider(providerName: DataSource) {
|
||||||
|
switch (providerName) {
|
||||||
|
case DataSource.ALPHA_VANTAGE:
|
||||||
|
return this.alphaVantageService;
|
||||||
|
case DataSource.GHOSTFOLIO:
|
||||||
|
return this.ghostfolioScraperApiService;
|
||||||
|
case DataSource.RAKUTEN:
|
||||||
|
return this.rakutenRapidApiService;
|
||||||
|
case DataSource.YAHOO:
|
||||||
|
return this.yahooFinanceService;
|
||||||
|
default:
|
||||||
|
throw new Error('No data provider has been found.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import { isAfter, isBefore, parse } from 'date-fns';
|
import { isAfter, isBefore, parse } from 'date-fns';
|
||||||
|
|
||||||
import { ConfigurationService } from '../../configuration.service';
|
import { ConfigurationService } from '../../configuration.service';
|
||||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||||
import { Granularity } from '../../interfaces/granularity.type';
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
@ -22,6 +24,10 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public canHandle(symbol: string) {
|
||||||
|
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
|
||||||
|
}
|
||||||
|
|
||||||
public async get(
|
public async get(
|
||||||
aSymbols: string[]
|
aSymbols: string[]
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
@ -77,7 +83,17 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public search(aSymbol: string) {
|
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||||
return this.alphaVantage.data.search(aSymbol);
|
const result = await this.alphaVantage.data.search(aSymbol);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: result?.bestMatches?.map((bestMatch) => {
|
||||||
|
return {
|
||||||
|
dataSource: DataSource.ALPHA_VANTAGE,
|
||||||
|
name: bestMatch['2. name'],
|
||||||
|
symbol: bestMatch['1. symbol']
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
import { getYesterday } from '@ghostfolio/helper';
|
import {
|
||||||
|
getYesterday,
|
||||||
|
isGhostfolioScraperApiSymbol
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
@ -6,7 +10,6 @@ import * as cheerio from 'cheerio';
|
|||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||||
import { Granularity } from '../../interfaces/granularity.type';
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse,
|
||||||
@ -21,6 +24,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
|
|
||||||
public constructor(private prisma: PrismaService) {}
|
public constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
public canHandle(symbol: string) {
|
||||||
|
return isGhostfolioScraperApiSymbol(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
public async get(
|
public async get(
|
||||||
aSymbols: string[]
|
aSymbols: string[]
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
@ -117,6 +124,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async search(aSymbol: string) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
private extractNumberFromString(aString: string): number {
|
private extractNumberFromString(aString: string): number {
|
||||||
try {
|
try {
|
||||||
const [numberString] = aString.match(
|
const [numberString] = aString.match(
|
||||||
|
@ -1,4 +1,9 @@
|
|||||||
import { getToday, getYesterday } from '@ghostfolio/helper';
|
import {
|
||||||
|
getToday,
|
||||||
|
getYesterday,
|
||||||
|
isRakutenRapidApiSymbol
|
||||||
|
} from '@ghostfolio/common/helper';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
@ -6,7 +11,6 @@ import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
|||||||
|
|
||||||
import { ConfigurationService } from '../../configuration.service';
|
import { ConfigurationService } from '../../configuration.service';
|
||||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||||
import { Granularity } from '../../interfaces/granularity.type';
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse,
|
||||||
@ -24,6 +28,13 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
private readonly configurationService: ConfigurationService
|
private readonly configurationService: ConfigurationService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public canHandle(symbol: string) {
|
||||||
|
return (
|
||||||
|
isRakutenRapidApiSymbol(symbol) &&
|
||||||
|
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public async get(
|
public async get(
|
||||||
aSymbols: string[]
|
aSymbols: string[]
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
@ -117,6 +128,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async search(aSymbol: string) {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
public setPrisma(aPrismaService: PrismaService) {
|
||||||
|
this.prisma = aPrismaService;
|
||||||
|
}
|
||||||
|
|
||||||
private async getFearAndGreedIndex(): Promise<{
|
private async getFearAndGreedIndex(): Promise<{
|
||||||
now: { value: number; valueText: string };
|
now: { value: number; valueText: string };
|
||||||
previousClose: { value: number; valueText: string };
|
previousClose: { value: number; valueText: string };
|
||||||
@ -147,8 +166,4 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public setPrisma(aPrismaService: PrismaService) {
|
|
||||||
this.prisma = aPrismaService;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
/*
|
|
||||||
import { Test } from '@nestjs/testing';
|
|
||||||
|
|
||||||
import { YahooFinanceService } from './yahoo-finance.service';
|
|
||||||
|
|
||||||
describe('AppService', () => {
|
|
||||||
let service: YahooFinanceService;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
|
||||||
const app = await Test.createTestingModule({
|
|
||||||
imports: [],
|
|
||||||
providers: [YahooFinanceService]
|
|
||||||
}).compile();
|
|
||||||
|
|
||||||
service = app.get<YahooFinanceService>(YahooFinanceService);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('get', () => {
|
|
||||||
it('should return data for USDCHF', () => {
|
|
||||||
expect(service.get(['USDCHF'])).toEqual('{}');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
*/
|
|
@ -1,11 +1,14 @@
|
|||||||
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/helper';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
|
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/common/helper';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
import * as bent from 'bent';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import * as yahooFinance from 'yahoo-finance';
|
import * as yahooFinance from 'yahoo-finance';
|
||||||
|
|
||||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||||
import { Granularity } from '../../interfaces/granularity.type';
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse,
|
||||||
@ -21,8 +24,14 @@ import {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class YahooFinanceService implements DataProviderInterface {
|
export class YahooFinanceService implements DataProviderInterface {
|
||||||
|
private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
|
||||||
|
|
||||||
public constructor() {}
|
public constructor() {}
|
||||||
|
|
||||||
|
public canHandle(symbol: string) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public async get(
|
public async get(
|
||||||
aSymbols: string[]
|
aSymbols: string[]
|
||||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||||
@ -135,6 +144,49 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||||
|
let items = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
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`,
|
||||||
|
'GET',
|
||||||
|
'json',
|
||||||
|
200
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await get();
|
||||||
|
items = result.quotes
|
||||||
|
.filter((quote) => {
|
||||||
|
return quote.isYahooFinance;
|
||||||
|
})
|
||||||
|
.filter(({ quoteType }) => {
|
||||||
|
return (
|
||||||
|
quoteType === 'CRYPTOCURRENCY' ||
|
||||||
|
quoteType === 'EQUITY' ||
|
||||||
|
quoteType === 'ETF'
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(({ quoteType, symbol }) => {
|
||||||
|
if (quoteType === 'CRYPTOCURRENCY') {
|
||||||
|
// Only allow cryptocurrencies in USD
|
||||||
|
return symbol.includes('USD');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map(({ longname, shortname, symbol }) => {
|
||||||
|
return {
|
||||||
|
dataSource: DataSource.YAHOO,
|
||||||
|
name: longname || shortname,
|
||||||
|
symbol: convertFromYahooSymbol(symbol)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a symbol to a Yahoo symbol
|
* Converts a symbol to a Yahoo symbol
|
||||||
*
|
*
|
||||||
@ -170,7 +222,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
private parseExchange(aString: string): string {
|
private parseExchange(aString: string): string {
|
||||||
if (aString?.toLowerCase() === 'ccc') {
|
if (aString?.toLowerCase() === 'ccc') {
|
||||||
return 'Other';
|
return UNKNOWN_KEY;
|
||||||
}
|
}
|
||||||
|
|
||||||
return aString;
|
return aString;
|
||||||
@ -200,7 +252,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return Industry.Software;
|
return Industry.Software;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Industry.Other;
|
return Industry.Unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseSector(aString: string): Sector {
|
private parseSector(aString: string): Sector {
|
||||||
@ -222,7 +274,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return Sector.Technology;
|
return Sector.Technology;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Sector.Other;
|
return Sector.Unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseType(aString: string): Type {
|
private parseType(aString: string): Type {
|
||||||
@ -234,7 +286,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
return Type.Stock;
|
return Type.Stock;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Type.Other;
|
return Type.Unknown;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { getYesterday } from '@ghostfolio/helper';
|
import { getYesterday } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
import { Granularity } from './granularity.type';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse
|
IDataProviderResponse
|
||||||
} from './interfaces';
|
} from './interfaces';
|
||||||
|
|
||||||
export interface DataProviderInterface {
|
export interface DataProviderInterface {
|
||||||
|
canHandle(symbol: string): boolean;
|
||||||
|
|
||||||
get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>;
|
||||||
|
|
||||||
getHistorical(
|
getHistorical(
|
||||||
@ -15,4 +19,6 @@ export interface DataProviderInterface {
|
|||||||
): Promise<{
|
): Promise<{
|
||||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
|
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
||||||
}
|
}
|
||||||
|
@ -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_SOURCES: string | string[]; // string is not correct, error in envalid?
|
||||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||||
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
|
||||||
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { Account, Currency, DataSource, Platform } from '@prisma/client';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
|
import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
|
||||||
|
|
||||||
import { OrderType } from '../../models/order-type';
|
import { OrderType } from '../../models/order-type';
|
||||||
|
|
||||||
@ -7,9 +8,9 @@ export const Industry = {
|
|||||||
Biotechnology: 'Biotechnology',
|
Biotechnology: 'Biotechnology',
|
||||||
Food: 'Food',
|
Food: 'Food',
|
||||||
Internet: 'Internet',
|
Internet: 'Internet',
|
||||||
Other: 'Other',
|
|
||||||
Pharmaceutical: 'Pharmaceutical',
|
Pharmaceutical: 'Pharmaceutical',
|
||||||
Software: 'Software'
|
Software: 'Software',
|
||||||
|
Unknown: UNKNOWN_KEY
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MarketState = {
|
export const MarketState = {
|
||||||
@ -21,15 +22,15 @@ export const MarketState = {
|
|||||||
export const Sector = {
|
export const Sector = {
|
||||||
Consumer: 'Consumer',
|
Consumer: 'Consumer',
|
||||||
Healthcare: 'Healthcare',
|
Healthcare: 'Healthcare',
|
||||||
Other: 'Other',
|
Technology: 'Technology',
|
||||||
Technology: 'Technology'
|
Unknown: UNKNOWN_KEY
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Type = {
|
export const Type = {
|
||||||
Cryptocurrency: 'Cryptocurrency',
|
Cryptocurrency: 'Cryptocurrency',
|
||||||
ETF: 'ETF',
|
ETF: 'ETF',
|
||||||
Other: 'Other',
|
Stock: 'Stock',
|
||||||
Stock: 'Stock'
|
Unknown: UNKNOWN_KEY
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IOrder {
|
export interface IOrder {
|
||||||
@ -40,6 +41,7 @@ export interface IOrder {
|
|||||||
id?: string;
|
id?: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
|
symbolProfile: SymbolProfile;
|
||||||
type: OrderType;
|
type: OrderType;
|
||||||
unitPrice: number;
|
unitPrice: number;
|
||||||
}
|
}
|
||||||
@ -64,6 +66,12 @@ export interface IDataProviderResponse {
|
|||||||
url?: string;
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IDataGatheringItem {
|
||||||
|
dataSource: DataSource;
|
||||||
|
date?: Date;
|
||||||
|
symbol: string;
|
||||||
|
}
|
||||||
|
|
||||||
export type Industry = typeof Industry[keyof typeof Industry];
|
export type Industry = typeof Industry[keyof typeof Industry];
|
||||||
|
|
||||||
export type MarketState = typeof MarketState[keyof typeof MarketState];
|
export type MarketState = typeof MarketState[keyof typeof MarketState];
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_DATE_FORMAT,
|
DEFAULT_DATE_FORMAT,
|
||||||
DEFAULT_DATE_FORMAT_MONTH_YEAR
|
DEFAULT_DATE_FORMAT_MONTH_YEAR
|
||||||
} from '@ghostfolio/helper';
|
} from '@ghostfolio/common/config';
|
||||||
|
|
||||||
export const DateFormats = {
|
export const DateFormats = {
|
||||||
display: {
|
display: {
|
||||||
|
@ -33,23 +33,23 @@ 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: 'analysis',
|
|
||||||
loadChildren: () =>
|
|
||||||
import('./pages/analysis/analysis-page.module').then(
|
|
||||||
(m) => m.AnalysisPageModule
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'home',
|
path: 'home',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'report',
|
path: 'pricing',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/report/report-page.module').then(
|
import('./pages/pricing/pricing-page.module').then(
|
||||||
(m) => m.ReportPageModule
|
(m) => m.PricingPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'register',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/register/register-page.module').then(
|
||||||
|
(m) => m.RegisterPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -62,7 +62,28 @@ const routes: Routes = [
|
|||||||
{
|
{
|
||||||
path: 'start',
|
path: 'start',
|
||||||
loadChildren: () =>
|
loadChildren: () =>
|
||||||
import('./pages/login/login-page.module').then((m) => m.LoginPageModule)
|
import('./pages/landing/landing-page.module').then(
|
||||||
|
(m) => m.LandingPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tools',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/tools/tools-page.module').then((m) => m.ToolsPageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tools/analysis',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/tools/analysis/analysis-page.module').then(
|
||||||
|
(m) => m.AnalysisPageModule
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'tools/report',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/tools/report/report-page.module').then(
|
||||||
|
(m) => m.ReportPageModule
|
||||||
|
)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'transactions',
|
path: 'transactions',
|
||||||
@ -71,11 +92,16 @@ const routes: Routes = [
|
|||||||
(m) => m.TransactionsPageModule
|
(m) => m.TransactionsPageModule
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'zen',
|
||||||
|
loadChildren: () =>
|
||||||
|
import('./pages/zen/zen-page.module').then((m) => m.ZenPageModule)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// wildcard, if requested url doesn't match any paths for routes defined
|
// wildcard, if requested url doesn't match any paths for routes defined
|
||||||
// earlier
|
// earlier
|
||||||
path: '**',
|
path: '**',
|
||||||
redirectTo: '/home',
|
redirectTo: 'home',
|
||||||
pathMatch: 'full'
|
pathMatch: 'full'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
[currentRoute]="currentRoute"
|
[currentRoute]="currentRoute"
|
||||||
[info]="info"
|
[info]="info"
|
||||||
[user]="user"
|
[user]="user"
|
||||||
|
(signOut)="onSignOut()"
|
||||||
></gf-header>
|
></gf-header>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -11,13 +12,15 @@
|
|||||||
<div *ngIf="canCreateAccount" class="container create-account-container">
|
<div *ngIf="canCreateAccount" class="container create-account-container">
|
||||||
<div class="row mb-5">
|
<div class="row mb-5">
|
||||||
<div class="col-md-6 offset-md-3">
|
<div class="col-md-6 offset-md-3">
|
||||||
<div
|
<a [routerLink]="['/']">
|
||||||
class="create-account-box p-2 text-center"
|
<mat-card
|
||||||
(click)="onCreateAccount()"
|
class="create-account-box p-2 text-center"
|
||||||
|
(click)="onCreateAccount()"
|
||||||
|
>
|
||||||
|
<div class="mt-1" i18n>You are using the Live Demo.</div>
|
||||||
|
<button mat-button color="primary" i18n>Create Account</button>
|
||||||
|
</mat-card></a
|
||||||
>
|
>
|
||||||
<div class="mt-1" i18n>You are using the Live Demo.</div>
|
|
||||||
<button mat-button color="primary" i18n>Create Account</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -25,7 +28,10 @@
|
|||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer class="footer d-flex justify-content-center position-absolute w-100">
|
<footer
|
||||||
|
*ngIf="currentRoute === 'start' || deviceType !== 'mobile'"
|
||||||
|
class="footer d-flex justify-content-center position-absolute w-100"
|
||||||
|
>
|
||||||
<div class="container text-center">
|
<div class="container text-center">
|
||||||
<div>
|
<div>
|
||||||
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||||
|
@ -5,8 +5,6 @@
|
|||||||
padding: 5rem 0;
|
padding: 5rem 0;
|
||||||
|
|
||||||
.create-account-box {
|
.create-account-box {
|
||||||
border: 1px solid rgba(var(--palette-primary-500), 1);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 90%;
|
font-size: 90%;
|
||||||
|
|
||||||
|
@ -5,22 +5,19 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { NavigationEnd, Router } from '@angular/router';
|
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
||||||
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface';
|
import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
|
||||||
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
|
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
hasPermission,
|
|
||||||
permissions,
|
|
||||||
primaryColorHex,
|
|
||||||
secondaryColorHex
|
|
||||||
} from '@ghostfolio/helper';
|
|
||||||
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { filter, takeUntil } from 'rxjs/operators';
|
import { filter, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { environment } from '../environments/environment';
|
import { environment } from '../environments/environment';
|
||||||
import { DataService } from './services/data.service';
|
import { DataService } from './services/data.service';
|
||||||
import { TokenStorageService } from './services/token-storage.service';
|
import { TokenStorageService } from './services/token-storage.service';
|
||||||
|
import { UserService } from './services/user/user.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-root',
|
selector: 'gf-root',
|
||||||
@ -32,57 +29,70 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
public canCreateAccount: boolean;
|
public canCreateAccount: boolean;
|
||||||
public currentRoute: string;
|
public currentRoute: string;
|
||||||
public currentYear = new Date().getFullYear();
|
public currentYear = new Date().getFullYear();
|
||||||
|
public deviceType: string;
|
||||||
public info: InfoItem;
|
public info: InfoItem;
|
||||||
public isLoggedIn = false;
|
|
||||||
public user: User;
|
public user: User;
|
||||||
public version = environment.version;
|
public version = environment.version;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private cd: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
private materialCssVarsService: MaterialCssVarsService,
|
private materialCssVarsService: MaterialCssVarsService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private tokenStorageService: TokenStorageService
|
private tokenStorageService: TokenStorageService,
|
||||||
|
private userService: UserService
|
||||||
) {
|
) {
|
||||||
this.initializeTheme();
|
this.initializeTheme();
|
||||||
this.user = undefined;
|
this.user = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
this.dataService.fetchInfo().subscribe((info) => {
|
this.dataService.fetchInfo().subscribe((info) => {
|
||||||
this.info = info;
|
this.info = info;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.router.events
|
this.router.events
|
||||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||||
.subscribe((test) => {
|
|
||||||
this.currentRoute = this.router.url.toString().substring(1);
|
|
||||||
// this.initializeTheme();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.tokenStorageService
|
|
||||||
.onChangeHasToken()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.isLoggedIn = !!this.tokenStorageService.getToken();
|
const urlTree = this.router.parseUrl(this.router.url);
|
||||||
|
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
|
||||||
if (this.isLoggedIn) {
|
const urlSegments = urlSegmentGroup.segments;
|
||||||
this.dataService.fetchUser().subscribe((user) => {
|
this.currentRoute = urlSegments[0].path;
|
||||||
this.user = user;
|
|
||||||
|
|
||||||
this.canCreateAccount = hasPermission(
|
|
||||||
this.user.permissions,
|
|
||||||
permissions.createUserAccount
|
|
||||||
);
|
|
||||||
|
|
||||||
this.cd.markForCheck();
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.user = null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.canCreateAccount = hasPermission(
|
||||||
|
this.user?.permissions,
|
||||||
|
permissions.createUserAccount
|
||||||
|
);
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onCreateAccount() {
|
||||||
|
this.tokenStorageService.signOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onSignOut() {
|
||||||
|
this.tokenStorageService.signOut();
|
||||||
|
this.userService.remove();
|
||||||
|
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private initializeTheme() {
|
private initializeTheme() {
|
||||||
@ -97,14 +107,4 @@ export class AppComponent implements OnDestroy, OnInit {
|
|||||||
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
||||||
this.materialCssVarsService.setAccentColor(secondaryColorHex);
|
this.materialCssVarsService.setAccentColor(secondaryColorHex);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCreateAccount() {
|
|
||||||
this.tokenStorageService.signOut();
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnDestroy() {
|
|
||||||
this.unsubscribeSubject.next();
|
|
||||||
this.unsubscribeSubject.complete();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { Platform } from '@angular/cdk/platform';
|
|||||||
import { HttpClientModule } from '@angular/common/http';
|
import { HttpClientModule } from '@angular/common/http';
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import {
|
import {
|
||||||
DateAdapter,
|
DateAdapter,
|
||||||
MAT_DATE_FORMATS,
|
MAT_DATE_FORMATS,
|
||||||
@ -22,7 +23,7 @@ import { AppComponent } from './app.component';
|
|||||||
import { GfHeaderModule } from './components/header/header.module';
|
import { GfHeaderModule } from './components/header/header.module';
|
||||||
import { authInterceptorProviders } from './core/auth.interceptor';
|
import { authInterceptorProviders } from './core/auth.interceptor';
|
||||||
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
|
||||||
import { LanguageManager } from './core/language-manager.service';
|
import { LanguageService } from './core/language.service';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
declarations: [AppComponent],
|
declarations: [AppComponent],
|
||||||
@ -34,6 +35,7 @@ import { LanguageManager } from './core/language-manager.service';
|
|||||||
HttpClientModule,
|
HttpClientModule,
|
||||||
MarkdownModule.forRoot(),
|
MarkdownModule.forRoot(),
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
MaterialCssVarsModule.forRoot({
|
MaterialCssVarsModule.forRoot({
|
||||||
darkThemeClass: 'is-dark-theme',
|
darkThemeClass: 'is-dark-theme',
|
||||||
isAutoContrast: true,
|
isAutoContrast: true,
|
||||||
@ -46,11 +48,11 @@ import { LanguageManager } from './core/language-manager.service';
|
|||||||
providers: [
|
providers: [
|
||||||
authInterceptorProviders,
|
authInterceptorProviders,
|
||||||
httpResponseInterceptorProviders,
|
httpResponseInterceptorProviders,
|
||||||
LanguageManager,
|
LanguageService,
|
||||||
{
|
{
|
||||||
provide: DateAdapter,
|
provide: DateAdapter,
|
||||||
useClass: CustomDateAdapter,
|
useClass: CustomDateAdapter,
|
||||||
deps: [LanguageManager, MAT_DATE_LOCALE, Platform]
|
deps: [LanguageService, MAT_DATE_LOCALE, Platform]
|
||||||
},
|
},
|
||||||
{ provide: MAT_DATE_FORMATS, useValue: DateFormats }
|
{ provide: MAT_DATE_FORMATS, useValue: DateFormats }
|
||||||
],
|
],
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
<table mat-table [dataSource]="dataSource" class="w-100">
|
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||||
<ng-container matColumnDef="granteeAlias">
|
<ng-container matColumnDef="granteeAlias">
|
||||||
<th mat-header-cell *matHeaderCellDef i18n>User</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>User</th>
|
||||||
<td mat-cell *matCellDef="let element">
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
{{ element.granteeAlias }}
|
{{ element.granteeAlias }}
|
||||||
</td></ng-container
|
</td></ng-container
|
||||||
>
|
>
|
||||||
|
|
||||||
<ng-container matColumnDef="type">
|
<ng-container matColumnDef="type">
|
||||||
<th mat-header-cell *matHeaderCellDef i18n>Type</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
||||||
<td mat-cell *matCellDef="let element">
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
||||||
Restricted Access
|
Restricted Access
|
||||||
</td></ng-container
|
</td></ng-container
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,7 @@ import {
|
|||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatTableDataSource } from '@angular/material/table';
|
import { MatTableDataSource } from '@angular/material/table';
|
||||||
import { Access } from '@ghostfolio/api/app/access/interfaces/access.interface';
|
import { Access } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-access-table',
|
selector: 'gf-access-table',
|
||||||
|
@ -1,42 +1,14 @@
|
|||||||
<table
|
<table class="gf-table w-100" mat-table [dataSource]="dataSource">
|
||||||
class="w-100"
|
|
||||||
matSort
|
|
||||||
matSortActive="account"
|
|
||||||
matSortDirection="desc"
|
|
||||||
mat-table
|
|
||||||
[dataSource]="dataSource"
|
|
||||||
>
|
|
||||||
<ng-container matColumnDef="account">
|
<ng-container matColumnDef="account">
|
||||||
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Name</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
|
||||||
<td *matCellDef="let element" mat-cell>
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
{{ element.name }}
|
{{ element.name }}
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="type">
|
|
||||||
<th
|
|
||||||
*matHeaderCellDef
|
|
||||||
class="d-none d-lg-table-cell justify-content-center"
|
|
||||||
i18n
|
|
||||||
mat-header-cell
|
|
||||||
mat-sort-header
|
|
||||||
>
|
|
||||||
Type
|
|
||||||
</th>
|
|
||||||
<td
|
|
||||||
mat-cell
|
|
||||||
*matCellDef="let element"
|
|
||||||
class="d-none d-lg-table-cell text-center"
|
|
||||||
>
|
|
||||||
<div class="d-inline-flex justify-content-center px-2 py-1 type-badge">
|
|
||||||
<span>{{ element.accountType }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="platform">
|
<ng-container matColumnDef="platform">
|
||||||
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Platform</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Platform</th>
|
||||||
<td mat-cell *matCellDef="let element">
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<gf-symbol-icon
|
<gf-symbol-icon
|
||||||
*ngIf="element.Platform?.url"
|
*ngIf="element.Platform?.url"
|
||||||
@ -50,8 +22,8 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<th *matHeaderCellDef class="px-0 text-center" i18n mat-header-cell></th>
|
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
||||||
<td *matCellDef="let element" class="px-0 text-center" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
<button
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
@ -77,7 +49,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="transactions">
|
<ng-container matColumnDef="transactions">
|
||||||
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Transactions</th>
|
<th *matHeaderCellDef i18n mat-header-cell>Transactions</th>
|
||||||
<td *matCellDef="let element" mat-cell>
|
<td *matCellDef="let element" mat-cell>
|
||||||
{{ element.Order?.length }}
|
{{ element.Order?.length }}
|
||||||
</td>
|
</td>
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
@ -8,10 +10,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mat-table {
|
.mat-table {
|
||||||
td {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
th {
|
||||||
::ng-deep {
|
::ng-deep {
|
||||||
.mat-sort-header-container {
|
.mat-sort-header-container {
|
||||||
@ -19,21 +17,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mat-row {
|
|
||||||
&:nth-child(even) {
|
|
||||||
background-color: rgba(
|
|
||||||
var(--dark-primary-text),
|
|
||||||
var(--palette-background-hover-alpha)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-badge {
|
|
||||||
background-color: rgba(var(--dark-primary-text), 0.05);
|
|
||||||
border-radius: 1rem;
|
|
||||||
line-height: 1em;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -41,19 +24,4 @@
|
|||||||
.mat-form-field {
|
.mat-form-field {
|
||||||
color: rgba(var(--light-primary-text));
|
color: rgba(var(--light-primary-text));
|
||||||
}
|
}
|
||||||
|
|
||||||
.mat-table {
|
|
||||||
.mat-row {
|
|
||||||
&:nth-child(even) {
|
|
||||||
background-color: rgba(
|
|
||||||
var(--light-primary-text),
|
|
||||||
var(--palette-background-hover-alpha)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-badge {
|
|
||||||
background-color: rgba(var(--light-primary-text), 0.1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -6,13 +6,9 @@ import {
|
|||||||
OnChanges,
|
OnChanges,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
Output,
|
Output
|
||||||
ViewChild
|
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
|
||||||
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 { Account as AccountModel } from '@prisma/client';
|
import { Account as AccountModel } from '@prisma/client';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { Subject, Subscription } from 'rxjs';
|
||||||
|
|
||||||
@ -32,8 +28,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
@Output() accountDeleted = new EventEmitter<string>();
|
@Output() accountDeleted = new EventEmitter<string>();
|
||||||
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
||||||
|
|
||||||
@ViewChild(MatSort) sort: MatSort;
|
|
||||||
|
|
||||||
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
|
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
|
||||||
public displayedColumns = [];
|
public displayedColumns = [];
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
@ -41,16 +35,12 @@ export class AccountsTableComponent implements OnChanges, 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
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
this.displayedColumns = ['account', 'type', 'platform', 'transactions'];
|
this.displayedColumns = ['account', 'platform', 'transactions'];
|
||||||
|
|
||||||
if (this.showActions) {
|
if (this.showActions) {
|
||||||
this.displayedColumns.push('actions');
|
this.displayedColumns.push('actions');
|
||||||
@ -60,7 +50,6 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
if (this.accounts) {
|
if (this.accounts) {
|
||||||
this.dataSource = new MatTableDataSource(this.accounts);
|
this.dataSource = new MatTableDataSource(this.accounts);
|
||||||
this.dataSource.sort = this.sort;
|
|
||||||
|
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,6 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
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 { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||||
@ -22,7 +21,6 @@ import { AccountsTableComponent } from './accounts-table.component';
|
|||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
MatSortModule,
|
|
||||||
MatTableModule,
|
MatTableModule,
|
||||||
NgxSkeletonLoaderModule,
|
NgxSkeletonLoaderModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
|
@ -5,7 +5,7 @@ import {
|
|||||||
OnChanges,
|
OnChanges,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { resolveFearAndGreedIndex } from '@ghostfolio/helper';
|
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-fear-and-greed-index',
|
selector: 'gf-fear-and-greed-index',
|
||||||
|
@ -6,67 +6,100 @@
|
|||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block"
|
class="d-none d-sm-block"
|
||||||
[routerLink]="['/']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'home' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen',
|
||||||
|
'text-decoration-underline':
|
||||||
|
currentRoute === 'home' || currentRoute === 'zen'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/']"
|
||||||
>Overview</a
|
>Overview</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
|
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
[routerLink]="['/analysis']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'analysis' ? 'primary' : null"
|
[ngClass]="{
|
||||||
>Analysis</a
|
'font-weight-bold':
|
||||||
|
currentRoute === 'analysis' ||
|
||||||
|
currentRoute === 'report' ||
|
||||||
|
currentRoute === 'tools',
|
||||||
|
'text-decoration-underline':
|
||||||
|
currentRoute === 'analysis' ||
|
||||||
|
currentRoute === 'report' ||
|
||||||
|
currentRoute === 'tools'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/tools']"
|
||||||
|
>Tools</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
[routerLink]="['/report']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'report' ? 'primary' : null"
|
[ngClass]="{
|
||||||
>X-ray</a
|
'font-weight-bold': currentRoute === 'transactions',
|
||||||
>
|
'text-decoration-underline': currentRoute === 'transactions'
|
||||||
<a
|
}"
|
||||||
class="d-none d-sm-block mx-1"
|
|
||||||
[routerLink]="['/transactions']"
|
[routerLink]="['/transactions']"
|
||||||
i18n
|
|
||||||
mat-flat-button
|
|
||||||
[color]="currentRoute === 'transactions' ? 'primary' : null"
|
|
||||||
>Transactions</a
|
>Transactions</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
[routerLink]="['/accounts']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'accounts' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'accounts',
|
||||||
|
'text-decoration-underline': currentRoute === 'accounts'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/accounts']"
|
||||||
>Accounts</a
|
>Accounts</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
*ngIf="hasPermissionToAccessAdminControl"
|
*ngIf="hasPermissionToAccessAdminControl"
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
[routerLink]="['/admin']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'admin' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'admin',
|
||||||
|
'text-decoration-underline': currentRoute === 'admin'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/admin']"
|
||||||
>Admin Control</a
|
>Admin Control</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
[routerLink]="['/resources']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'resources' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'resources',
|
||||||
|
'text-decoration-underline': currentRoute === 'resources'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/resources']"
|
||||||
>Resources</a
|
>Resources</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
|
*ngIf="hasPermissionForSubscription"
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
[routerLink]="['/about']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'about' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'pricing',
|
||||||
|
'text-decoration-underline': currentRoute === 'pricing'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/pricing']"
|
||||||
|
>Pricing</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="d-none d-sm-block mx-1"
|
||||||
|
i18n
|
||||||
|
mat-flat-button
|
||||||
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'about',
|
||||||
|
'text-decoration-underline': currentRoute === 'about'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/about']"
|
||||||
>About</a
|
>About</a
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
@ -126,69 +159,80 @@
|
|||||||
<hr class="m-0" />
|
<hr class="m-0" />
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<a
|
<a
|
||||||
|
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
|
||||||
class="d-block d-sm-none"
|
class="d-block d-sm-none"
|
||||||
[routerLink]="['/analysis']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'analysis' }"
|
[ngClass]="{
|
||||||
>Analysis</a
|
'font-weight-bold':
|
||||||
|
currentRoute === 'analysis' ||
|
||||||
|
currentRoute === 'report' ||
|
||||||
|
currentRoute === 'tools'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/tools']"
|
||||||
|
>Tools</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="d-block d-sm-none"
|
class="d-block d-sm-none"
|
||||||
[routerLink]="['/report']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'report' }"
|
[ngClass]="{
|
||||||
>X-ray</a
|
'font-weight-bold': currentRoute === 'transactions'
|
||||||
>
|
}"
|
||||||
<a
|
|
||||||
class="d-block d-sm-none"
|
|
||||||
[routerLink]="['/transactions']"
|
[routerLink]="['/transactions']"
|
||||||
i18n
|
|
||||||
mat-menu-item
|
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'transactions' }"
|
|
||||||
>Transactions</a
|
>Transactions</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="d-block d-sm-none"
|
class="d-block d-sm-none"
|
||||||
[routerLink]="['/accounts']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
|
[ngClass]="{ 'font-weight-bold': currentRoute === 'accounts' }"
|
||||||
|
[routerLink]="['/accounts']"
|
||||||
>Accounts</a
|
>Accounts</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="align-items-center d-flex"
|
class="align-items-center d-flex"
|
||||||
[routerLink]="['/account']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
|
[ngClass]="{ 'font-weight-bold': currentRoute === 'account' }"
|
||||||
>Ghostfolio Account</a
|
[routerLink]="['/account']"
|
||||||
|
>My Ghostfolio</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
*ngIf="hasPermissionToAccessAdminControl"
|
*ngIf="hasPermissionToAccessAdminControl"
|
||||||
class="d-block d-sm-none"
|
class="d-block d-sm-none"
|
||||||
[routerLink]="['/admin']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
|
[ngClass]="{ 'font-weight-bold': currentRoute === 'admin' }"
|
||||||
|
[routerLink]="['/admin']"
|
||||||
>Admin Control</a
|
>Admin Control</a
|
||||||
>
|
>
|
||||||
<hr class="m-0" />
|
<hr class="m-0" />
|
||||||
<a
|
<a
|
||||||
class="d-block d-sm-none"
|
class="d-block d-sm-none"
|
||||||
[routerLink]="['/resources']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'resources' }"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'resources'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/resources']"
|
||||||
>Resources</a
|
>Resources</a
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
*ngIf="hasPermissionForSubscription"
|
||||||
|
class="d-block d-sm-none"
|
||||||
|
i18n
|
||||||
|
mat-menu-item
|
||||||
|
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }"
|
||||||
|
[routerLink]="['/pricing']"
|
||||||
|
>Pricing</a
|
||||||
|
>
|
||||||
<a
|
<a
|
||||||
class="d-block d-sm-none"
|
class="d-block d-sm-none"
|
||||||
[routerLink]="['/about']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-menu-item
|
mat-menu-item
|
||||||
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
|
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }"
|
||||||
|
[routerLink]="['/about']"
|
||||||
>About Ghostfolio</a
|
>About Ghostfolio</a
|
||||||
>
|
>
|
||||||
<hr class="d-block d-sm-none m-0" />
|
<hr class="d-block d-sm-none m-0" />
|
||||||
@ -198,27 +242,51 @@
|
|||||||
<ng-container *ngIf="user === null">
|
<ng-container *ngIf="user === null">
|
||||||
<a
|
<a
|
||||||
*ngIf="currentRoute && currentRoute !== 'start'"
|
*ngIf="currentRoute && currentRoute !== 'start'"
|
||||||
[routerLink]="['/']"
|
|
||||||
class="mx-2 no-min-width px-2"
|
class="mx-2 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
|
[routerLink]="['/']"
|
||||||
>
|
>
|
||||||
<gf-logo></gf-logo>
|
<gf-logo></gf-logo>
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
class="d-none d-sm-block mx-1"
|
||||||
[routerLink]="['/about']"
|
|
||||||
i18n
|
i18n
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
[color]="currentRoute === 'about' ? 'primary' : null"
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'about',
|
||||||
|
'text-decoration-underline': currentRoute === 'about'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/about']"
|
||||||
>About</a
|
>About</a
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="d-none d-sm-block mx-1"
|
*ngIf="hasPermissionForSubscription"
|
||||||
|
i18n
|
||||||
|
mat-flat-button
|
||||||
|
[ngClass]="{
|
||||||
|
'font-weight-bold': currentRoute === 'pricing',
|
||||||
|
'text-decoration-underline': currentRoute === 'pricing'
|
||||||
|
}"
|
||||||
|
[routerLink]="['/pricing']"
|
||||||
|
>Pricing</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="d-none d-sm-block mx-1 no-min-width px-1"
|
||||||
href="https://github.com/ghostfolio/ghostfolio"
|
href="https://github.com/ghostfolio/ghostfolio"
|
||||||
mat-flat-button
|
mat-flat-button
|
||||||
>GitHub</a
|
><ion-icon name="logo-github"></ion-icon
|
||||||
>
|
></a>
|
||||||
<button i18n mat-flat-button (click)="openLoginDialog()">Sign in</button>
|
<button class="mx-1" i18n mat-flat-button (click)="openLoginDialog()">
|
||||||
|
Sign In
|
||||||
|
</button>
|
||||||
|
<a
|
||||||
|
class="d-none d-sm-block"
|
||||||
|
color="primary"
|
||||||
|
i18n
|
||||||
|
mat-flat-button
|
||||||
|
[routerLink]="['/register']"
|
||||||
|
>Get Started
|
||||||
|
</a>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</mat-toolbar>
|
</mat-toolbar>
|
||||||
|
@ -1,18 +1,19 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
OnChanges
|
OnChanges,
|
||||||
|
Output
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { InfoItem } from '@ghostfolio/api/app/info/interfaces/info-item.interface';
|
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
||||||
import { User } from '@ghostfolio/api/app/user/interfaces/user.interface';
|
|
||||||
import { LoginWithAccessTokenDialog } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.component';
|
|
||||||
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 { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||||
import { hasPermission, permissions } from '@ghostfolio/helper';
|
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
import { EMPTY, Subject } from 'rxjs';
|
import { EMPTY, Subject } from 'rxjs';
|
||||||
import { catchError, takeUntil } from 'rxjs/operators';
|
import { catchError, takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
@ -27,8 +28,11 @@ export class HeaderComponent implements OnChanges {
|
|||||||
@Input() info: InfoItem;
|
@Input() info: InfoItem;
|
||||||
@Input() user: User;
|
@Input() user: User;
|
||||||
|
|
||||||
public hasPermissionToAccessAdminControl: boolean;
|
@Output() signOut = new EventEmitter<void>();
|
||||||
|
|
||||||
public hasPermissionForSocialLogin: boolean;
|
public hasPermissionForSocialLogin: boolean;
|
||||||
|
public hasPermissionForSubscription: boolean;
|
||||||
|
public hasPermissionToAccessAdminControl: boolean;
|
||||||
public impersonationId: string;
|
public impersonationId: string;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
@ -48,17 +52,20 @@ export class HeaderComponent implements OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
if (this.user) {
|
|
||||||
this.hasPermissionToAccessAdminControl = hasPermission(
|
|
||||||
this.user.permissions,
|
|
||||||
permissions.accessAdminControl
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.hasPermissionForSocialLogin = hasPermission(
|
this.hasPermissionForSocialLogin = hasPermission(
|
||||||
this.info?.globalPermissions,
|
this.info?.globalPermissions,
|
||||||
permissions.enableSocialLogin
|
permissions.enableSocialLogin
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.hasPermissionForSubscription = hasPermission(
|
||||||
|
this.info?.globalPermissions,
|
||||||
|
permissions.enableSubscription
|
||||||
|
);
|
||||||
|
|
||||||
|
this.hasPermissionToAccessAdminControl = hasPermission(
|
||||||
|
this.user?.permissions,
|
||||||
|
permissions.accessAdminControl
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public impersonateAccount(aId: string) {
|
public impersonateAccount(aId: string) {
|
||||||
@ -72,8 +79,7 @@ export class HeaderComponent implements OnChanges {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onSignOut() {
|
public onSignOut() {
|
||||||
this.tokenStorageService.signOut();
|
this.signOut.next();
|
||||||
window.location.reload();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public openLoginDialog(): void {
|
public openLoginDialog(): void {
|
||||||
|
@ -4,7 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
|||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/pages/login/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
|
||||||
|
|
||||||
import { GfLogoModule } from '../logo/logo.module';
|
import { GfLogoModule } from '../logo/logo.module';
|
||||||
import { HeaderComponent } from './header.component';
|
import { HeaderComponent } from './header.component';
|
||||||
|
@ -9,8 +9,8 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
|
import { primaryColorRgb } from '@ghostfolio/common/config';
|
||||||
import { primaryColorRgb } from '@ghostfolio/helper';
|
import { PortfolioItem } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
LineController,
|
LineController,
|
||||||
LineElement,
|
LineElement,
|
||||||
@ -36,10 +36,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
Chart.register(
|
Chart.register(
|
||||||
|
LinearScale,
|
||||||
LineController,
|
LineController,
|
||||||
LineElement,
|
LineElement,
|
||||||
PointElement,
|
PointElement,
|
||||||
LinearScale,
|
|
||||||
TimeScale
|
TimeScale
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -95,7 +95,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
responsive: true,
|
responsive: true,
|
||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
display: false,
|
display: true,
|
||||||
grid: {
|
grid: {
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
|
@ -9,7 +9,8 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/helper';
|
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
|
||||||
|
import { getBackgroundColor } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
Chart,
|
Chart,
|
||||||
Filler,
|
Filler,
|
||||||
@ -62,6 +63,10 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.chart?.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
private initialize() {
|
private initialize() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
const benchmarkPrices = [];
|
const benchmarkPrices = [];
|
||||||
@ -76,7 +81,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
const canvas = document.getElementById('chartCanvas');
|
const canvas = document.getElementById('chartCanvas');
|
||||||
|
|
||||||
var gradient = this.chartCanvas?.nativeElement
|
const gradient = this.chartCanvas?.nativeElement
|
||||||
?.getContext('2d')
|
?.getContext('2d')
|
||||||
.createLinearGradient(
|
.createLinearGradient(
|
||||||
0,
|
0,
|
||||||
@ -88,14 +93,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
0,
|
0,
|
||||||
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
|
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
|
||||||
);
|
);
|
||||||
gradient.addColorStop(
|
gradient.addColorStop(1, getBackgroundColor());
|
||||||
1,
|
|
||||||
getComputedStyle(document.documentElement).getPropertyValue(
|
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
|
||||||
? '--dark-background'
|
|
||||||
: '--light-background'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
labels,
|
labels,
|
||||||
@ -181,8 +179,4 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
|
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
|
||||||
this.chart?.destroy();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, Inject } from '@angular/core';
|
|||||||
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
import { MAT_DIALOG_DATA } from '@angular/material/dialog';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'login-with-access-token-dialog',
|
selector: 'gf-login-with-access-token-dialog',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
styleUrls: ['./login-with-access-token-dialog.scss'],
|
styleUrls: ['./login-with-access-token-dialog.scss'],
|
||||||
templateUrl: 'login-with-access-token-dialog.html'
|
templateUrl: 'login-with-access-token-dialog.html'
|
@ -28,7 +28,7 @@ export class PerformanceChartDialog {
|
|||||||
public title: string;
|
public title: string;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private cd: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
private dataService: DataService,
|
private dataService: DataService,
|
||||||
public dialogRef: MatDialogRef<PerformanceChartDialog>,
|
public dialogRef: MatDialogRef<PerformanceChartDialog>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
||||||
@ -46,7 +46,7 @@ export class PerformanceChartDialog {
|
|||||||
|
|
||||||
this.historicalDataItems = this.data.historicalDataItems;
|
this.historicalDataItems = this.data.historicalDataItems;
|
||||||
|
|
||||||
this.historicalDataItems.forEach((historicalDataItem) => {
|
this.historicalDataItems?.forEach((historicalDataItem) => {
|
||||||
const benchmarkItem = historicalData.find((item) => {
|
const benchmarkItem = historicalData.find((item) => {
|
||||||
return item.date === historicalDataItem.date;
|
return item.date === historicalDataItem.date;
|
||||||
});
|
});
|
||||||
@ -75,7 +75,7 @@ export class PerformanceChartDialog {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.cd.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.title = `Performance vs. ${this.benchmarkLabel}`;
|
this.title = `Performance vs. ${this.benchmarkLabel}`;
|
||||||
|
@ -5,7 +5,7 @@ import {
|
|||||||
OnChanges,
|
OnChanges,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PortfolioOverview } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-overview.interface';
|
import { PortfolioOverview } from '@ghostfolio/common/interfaces';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface';
|
import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
import { CountUp } from 'countup.js';
|
import { CountUp } from 'countup.js';
|
||||||
import { isNumber } from 'lodash';
|
import { isNumber } from 'lodash';
|
||||||
|
@ -4,7 +4,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PortfolioPerformance } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-performance.interface';
|
import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -7,7 +7,7 @@ import {
|
|||||||
OnChanges,
|
OnChanges,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PortfolioItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
|
import { PortfolioItem } from '@ghostfolio/common/interfaces';
|
||||||
import { endOfDay, parseISO, startOfDay } from 'date-fns';
|
import { endOfDay, parseISO, startOfDay } from 'date-fns';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
|
@ -7,7 +7,9 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
|
import { getCssVariable, getTextColor } from '@ghostfolio/common/helper';
|
||||||
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
import { Tooltip } from 'chart.js';
|
import { Tooltip } from 'chart.js';
|
||||||
import { LinearScale } from 'chart.js';
|
import { LinearScale } from 'chart.js';
|
||||||
@ -36,6 +38,14 @@ export class PortfolioProportionChartComponent
|
|||||||
public chart: Chart;
|
public chart: Chart;
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
|
|
||||||
|
private colorMap: {
|
||||||
|
[symbol: string]: string;
|
||||||
|
} = {
|
||||||
|
[UNKNOWN_KEY]: `rgba(${getTextColor()}, ${getCssVariable(
|
||||||
|
'--palette-foreground-divider-alpha'
|
||||||
|
)})`
|
||||||
|
};
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip);
|
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip);
|
||||||
}
|
}
|
||||||
@ -48,37 +58,67 @@ export class PortfolioProportionChartComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.chart?.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
private initialize() {
|
private initialize() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
const chartData: { [symbol: string]: number } = {};
|
const chartData: {
|
||||||
|
[symbol: string]: { color?: string; value: number };
|
||||||
|
} = {};
|
||||||
|
|
||||||
Object.keys(this.positions).forEach((symbol) => {
|
Object.keys(this.positions).forEach((symbol) => {
|
||||||
if (this.positions[symbol][this.key]) {
|
if (this.positions[symbol][this.key]) {
|
||||||
if (chartData[this.positions[symbol][this.key]]) {
|
if (chartData[this.positions[symbol][this.key]]) {
|
||||||
chartData[this.positions[symbol][this.key]] += this.positions[
|
chartData[this.positions[symbol][this.key]].value += this.positions[
|
||||||
symbol
|
symbol
|
||||||
].value;
|
].value;
|
||||||
} else {
|
} else {
|
||||||
chartData[this.positions[symbol][this.key]] = this.positions[
|
chartData[this.positions[symbol][this.key]] = {
|
||||||
symbol
|
value: this.positions[symbol].value
|
||||||
].value;
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (chartData[UNKNOWN_KEY]) {
|
||||||
|
chartData[UNKNOWN_KEY].value += this.positions[symbol].value;
|
||||||
|
} else {
|
||||||
|
chartData[UNKNOWN_KEY] = {
|
||||||
|
value: this.positions[symbol].value
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const chartDataSorted = Object.entries(chartData)
|
const chartDataSorted = Object.entries(chartData)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
return a[1] - b[1];
|
return a[1].value - b[1].value;
|
||||||
})
|
})
|
||||||
.reverse();
|
.reverse();
|
||||||
|
|
||||||
|
chartDataSorted.forEach(([symbol, item], index) => {
|
||||||
|
if (this.colorMap[symbol]) {
|
||||||
|
// Reuse color
|
||||||
|
item.color = this.colorMap[symbol];
|
||||||
|
} else {
|
||||||
|
const color = this.getColorPalette()[index];
|
||||||
|
|
||||||
|
// Store color for reuse
|
||||||
|
this.colorMap[symbol] = color;
|
||||||
|
|
||||||
|
item.color = color;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
backgroundColor: this.getColorPalette(),
|
backgroundColor: chartDataSorted.map(([, item]) => {
|
||||||
|
return item.color;
|
||||||
|
}),
|
||||||
borderWidth: 0,
|
borderWidth: 0,
|
||||||
data: chartDataSorted.map(([, value]) => {
|
data: chartDataSorted.map(([, item]) => {
|
||||||
return value;
|
return item.value;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@ -100,21 +140,14 @@ export class PortfolioProportionChartComponent
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
callbacks: {
|
callbacks: {
|
||||||
label: (context) => {
|
label: (context) => {
|
||||||
const label = data.labels[context.dataIndex];
|
const label =
|
||||||
|
context.label === UNKNOWN_KEY ? 'Other' : context.label;
|
||||||
|
|
||||||
if (this.isInPercent) {
|
if (this.isInPercent) {
|
||||||
const value =
|
const value = 100 * <number>context.raw;
|
||||||
100 *
|
|
||||||
data.datasets[context.datasetIndex].data[
|
|
||||||
context.dataIndex
|
|
||||||
];
|
|
||||||
return `${label} (${value.toFixed(2)}%)`;
|
return `${label} (${value.toFixed(2)}%)`;
|
||||||
} else {
|
} else {
|
||||||
const value =
|
const value = <number>context.raw;
|
||||||
data.datasets[context.datasetIndex].data[
|
|
||||||
context.dataIndex
|
|
||||||
];
|
|
||||||
|
|
||||||
return `${label} (${value.toLocaleString(this.locale, {
|
return `${label} (${value.toLocaleString(this.locale, {
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
minimumFractionDigits: 2
|
minimumFractionDigits: 2
|
||||||
@ -153,8 +186,4 @@ export class PortfolioProportionChartComponent
|
|||||||
'#cc5de8' // grape 5
|
'#cc5de8' // grape 5
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnDestroy() {
|
|
||||||
this.chart?.destroy();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -34,7 +34,7 @@ export class PositionDetailDialog {
|
|||||||
public transactionCount: number;
|
public transactionCount: number;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private cd: ChangeDetectorRef,
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
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
|
||||||
@ -127,7 +127,7 @@ export class PositionDetailDialog {
|
|||||||
this.benchmarkDataItems[0].value = this.averagePrice;
|
this.benchmarkDataItems[0].value = this.averagePrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cd.markForCheck();
|
this.changeDetectorRef.markForCheck();
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,9 @@
|
|||||||
<div class="h6 m-0 text-truncate">{{ position?.name }}</div>
|
<div class="h6 m-0 text-truncate">{{ position?.name }}</div>
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<span>{{ position?.symbol | gfSymbol }}</span>
|
<span>{{ position?.symbol | gfSymbol }}</span>
|
||||||
<span *ngIf="position?.exchange" class="ml-2 text-muted"
|
<span
|
||||||
|
*ngIf="position?.exchange && position?.exchange !== unknownKey"
|
||||||
|
class="ml-2 text-muted"
|
||||||
>({{ position.exchange }})</span
|
>({{ position.exchange }})</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
@ -7,8 +7,9 @@ import {
|
|||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
|
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
|
||||||
@ -27,7 +28,7 @@ export class PositionComponent implements OnDestroy, OnInit {
|
|||||||
@Input() position: PortfolioPosition;
|
@Input() position: PortfolioPosition;
|
||||||
@Input() range: string;
|
@Input() range: string;
|
||||||
|
|
||||||
public routeQueryParams: Subscription;
|
public unknownKey = UNKNOWN_KEY;
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
@ -36,7 +37,7 @@ export class PositionComponent implements OnDestroy, OnInit {
|
|||||||
private route: ActivatedRoute,
|
private route: ActivatedRoute,
|
||||||
private router: Router
|
private router: Router
|
||||||
) {
|
) {
|
||||||
this.routeQueryParams = route.queryParams
|
route.queryParams
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
.subscribe((params) => {
|
.subscribe((params) => {
|
||||||
if (
|
if (
|
||||||
|
@ -1,31 +1,30 @@
|
|||||||
<!--<mat-form-field appearance="outline" class="w-100">
|
|
||||||
<input #input autocomplete="off" matInput (keyup)="applyFilter($event)" />
|
|
||||||
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
|
|
||||||
</mat-form-field>-->
|
|
||||||
|
|
||||||
<table
|
<table
|
||||||
class="w-100"
|
class="gf-table w-100"
|
||||||
matSort
|
matSort
|
||||||
matSortActive="shareCurrent"
|
matSortActive="allocationCurrent"
|
||||||
matSortDirection="desc"
|
matSortDirection="desc"
|
||||||
mat-table
|
mat-table
|
||||||
[dataSource]="dataSource"
|
[dataSource]="dataSource"
|
||||||
>
|
>
|
||||||
<ng-container matColumnDef="symbol">
|
<ng-container matColumnDef="symbol">
|
||||||
<th mat-header-cell *matHeaderCellDef mat-sort-header i18n>Symbol</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.symbol | gfSymbol }}</td>
|
Symbol
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.symbol | gfSymbol }}
|
||||||
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="performance">
|
<ng-container matColumnDef="performance">
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell text-right"
|
class="d-none d-lg-table-cell px-1 text-right"
|
||||||
i18n
|
i18n
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
>
|
>
|
||||||
Performance
|
Performance
|
||||||
</th>
|
</th>
|
||||||
<td class="d-none d-lg-table-cell" mat-cell *matCellDef="let element">
|
<td class="d-none d-lg-table-cell px-1" mat-cell *matCellDef="let element">
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
[colorizeSign]="true"
|
[colorizeSign]="true"
|
||||||
@ -37,43 +36,43 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="shareInvestment">
|
<ng-container matColumnDef="allocationInvestment">
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="justify-content-end"
|
class="justify-content-end px-1"
|
||||||
i18n
|
i18n
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
mat-sort-header
|
mat-sort-header
|
||||||
>
|
>
|
||||||
Initial Share
|
Initial Allocation
|
||||||
</th>
|
</th>
|
||||||
<td mat-cell *matCellDef="let element">
|
<td mat-cell *matCellDef="let element">
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end px-1">
|
||||||
<gf-value
|
<gf-value
|
||||||
[isPercent]="true"
|
[isPercent]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
[value]="isLoading ? undefined : element.shareInvestment"
|
[value]="isLoading ? undefined : element.allocationInvestment"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="shareCurrent">
|
<ng-container matColumnDef="allocationCurrent">
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="justify-content-end"
|
class="justify-content-end px-1"
|
||||||
i18n
|
i18n
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
mat-sort-header
|
mat-sort-header
|
||||||
>
|
>
|
||||||
Current Share
|
Current Allocation
|
||||||
</th>
|
</th>
|
||||||
<td mat-cell *matCellDef="let element">
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
[isPercent]="true"
|
[isPercent]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
[value]="isLoading ? undefined : element.shareCurrent"
|
[value]="isLoading ? undefined : element.allocationCurrent"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
@ -8,10 +10,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mat-table {
|
.mat-table {
|
||||||
td {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
th {
|
||||||
::ng-deep {
|
::ng-deep {
|
||||||
.mat-sort-header-container {
|
.mat-sort-header-container {
|
||||||
@ -22,13 +20,6 @@
|
|||||||
|
|
||||||
.mat-row {
|
.mat-row {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:nth-child(even) {
|
|
||||||
background-color: rgba(
|
|
||||||
var(--dark-primary-text),
|
|
||||||
var(--palette-background-hover-alpha)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -37,15 +28,4 @@
|
|||||||
.mat-form-field {
|
.mat-form-field {
|
||||||
color: rgba(var(--light-primary-text));
|
color: rgba(var(--light-primary-text));
|
||||||
}
|
}
|
||||||
|
|
||||||
.mat-table {
|
|
||||||
.mat-row {
|
|
||||||
&:nth-child(even) {
|
|
||||||
background-color: rgba(
|
|
||||||
var(--light-primary-text),
|
|
||||||
var(--palette-background-hover-alpha)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -13,7 +13,7 @@ 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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||||
import { Order as OrderModel } from '@prisma/client';
|
import { Order as OrderModel } from '@prisma/client';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { Subject, Subscription } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
@ -73,8 +73,8 @@ export class PositionsTableComponent implements OnChanges, OnInit {
|
|||||||
this.displayedColumns = [
|
this.displayedColumns = [
|
||||||
'symbol',
|
'symbol',
|
||||||
'performance',
|
'performance',
|
||||||
'shareInvestment',
|
'allocationInvestment',
|
||||||
'shareCurrent'
|
'allocationCurrent'
|
||||||
];
|
];
|
||||||
|
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
@ -5,8 +5,8 @@ import {
|
|||||||
OnChanges,
|
OnChanges,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
|
|
||||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
|
import { PortfolioPosition } from '@ghostfolio/common/interfaces/portfolio-position.interface';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-positions',
|
selector: 'gf-positions',
|
||||||
|
@ -4,7 +4,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { PortfolioReportRule } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-report.interface';
|
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-rule',
|
selector: 'gf-rule',
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||||
import { PortfolioReportRule } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-report.interface';
|
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-rules',
|
selector: 'gf-rules',
|
||||||
|
@ -1,48 +1,56 @@
|
|||||||
<mat-form-field appearance="outline" class="w-100">
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
<input
|
|
||||||
#input
|
|
||||||
autocomplete="off"
|
|
||||||
matInput
|
|
||||||
placeholder="Search for transactions..."
|
|
||||||
(keyup)="applyFilter($event)"
|
|
||||||
/>
|
|
||||||
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
|
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
|
||||||
|
<mat-chip-list #chipList aria-label="Search keywords">
|
||||||
|
<mat-chip
|
||||||
|
*ngFor="let searchKeyword of searchKeywords"
|
||||||
|
class="mx-1 my-0 px-2 py-0"
|
||||||
|
matChipRemove
|
||||||
|
[removable]="true"
|
||||||
|
(removed)="removeKeyword(searchKeyword)"
|
||||||
|
>
|
||||||
|
{{ searchKeyword | gfSymbol }}
|
||||||
|
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
|
||||||
|
</mat-chip>
|
||||||
|
<input
|
||||||
|
#searchInput
|
||||||
|
name="close-outline"
|
||||||
|
[formControl]="searchControl"
|
||||||
|
[matAutocomplete]="autocomplete"
|
||||||
|
[matChipInputFor]="chipList"
|
||||||
|
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
|
||||||
|
[placeholder]="placeholder"
|
||||||
|
(matChipInputTokenEnd)="addKeyword($event)"
|
||||||
|
/>
|
||||||
|
</mat-chip-list>
|
||||||
|
<mat-autocomplete
|
||||||
|
#autocomplete="matAutocomplete"
|
||||||
|
(optionSelected)="keywordSelected($event)"
|
||||||
|
>
|
||||||
|
<mat-option *ngFor="let filter of filters | async" [value]="filter">
|
||||||
|
{{ filter | gfSymbol }}
|
||||||
|
</mat-option>
|
||||||
|
</mat-autocomplete>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
<table
|
<table
|
||||||
class="w-100"
|
class="gf-table w-100"
|
||||||
matSort
|
matSort
|
||||||
matSortActive="date"
|
matSortActive="date"
|
||||||
matSortDirection="desc"
|
matSortDirection="desc"
|
||||||
mat-table
|
mat-table
|
||||||
[dataSource]="dataSource"
|
[dataSource]="dataSource"
|
||||||
>
|
>
|
||||||
<ng-container matColumnDef="account">
|
|
||||||
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Account</th>
|
|
||||||
<td *matCellDef="let element" mat-cell>
|
|
||||||
<div class="d-flex">
|
|
||||||
<gf-symbol-icon
|
|
||||||
*ngIf="element.Account?.Platform?.url"
|
|
||||||
class="mr-1"
|
|
||||||
[tooltip]="element.Account?.Platform?.name"
|
|
||||||
[url]="element.Account?.Platform?.url"
|
|
||||||
></gf-symbol-icon>
|
|
||||||
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container matColumnDef="date">
|
<ng-container matColumnDef="date">
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="justify-content-center"
|
class="justify-content-center px-1"
|
||||||
i18n
|
i18n
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
mat-sort-header
|
mat-sort-header
|
||||||
>
|
>
|
||||||
Date
|
Date
|
||||||
</th>
|
</th>
|
||||||
<td mat-cell *matCellDef="let element">
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
<div class="d-flex justify-content-center">
|
<div class="d-flex justify-content-center">
|
||||||
{{ element.date | date: defaultDateFormat }}
|
{{ element.date | date: defaultDateFormat }}
|
||||||
</div>
|
</div>
|
||||||
@ -52,14 +60,14 @@
|
|||||||
<ng-container matColumnDef="type">
|
<ng-container matColumnDef="type">
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="justify-content-center"
|
class="justify-content-center px-1"
|
||||||
i18n
|
i18n
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
mat-sort-header
|
mat-sort-header
|
||||||
>
|
>
|
||||||
Type
|
Type
|
||||||
</th>
|
</th>
|
||||||
<td mat-cell *matCellDef="let element" class="text-center">
|
<td mat-cell *matCellDef="let element" class="px-1 text-center">
|
||||||
<div
|
<div
|
||||||
class="d-inline-flex justify-content-center pl-1 pr-2 py-1 type-badge"
|
class="d-inline-flex justify-content-center pl-1 pr-2 py-1 type-badge"
|
||||||
[ngClass]="element.type == 'BUY' ? 'buy' : 'sell'"
|
[ngClass]="element.type == 'BUY' ? 'buy' : 'sell'"
|
||||||
@ -78,21 +86,25 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="symbol">
|
<ng-container matColumnDef="symbol">
|
||||||
<th *matHeaderCellDef i18n mat-header-cell mat-sort-header>Symbol</th>
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||||
<td mat-cell *matCellDef="let element">{{ element.symbol | gfSymbol }}</td>
|
Symbol
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
{{ element.symbol | gfSymbol }}
|
||||||
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="currency">
|
<ng-container matColumnDef="currency">
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell justify-content-center"
|
class="d-none d-lg-table-cell justify-content-center px-1"
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
i18n
|
i18n
|
||||||
mat-sort-header
|
mat-sort-header
|
||||||
>
|
>
|
||||||
Currency
|
Currency
|
||||||
</th>
|
</th>
|
||||||
<td class="d-none d-lg-table-cell" mat-cell *matCellDef="let element">
|
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||||
<div class="d-flex justify-content-center">
|
<div class="d-flex justify-content-center">
|
||||||
{{ element.currency }}
|
{{ element.currency }}
|
||||||
</div>
|
</div>
|
||||||
@ -102,14 +114,14 @@
|
|||||||
<ng-container matColumnDef="quantity">
|
<ng-container matColumnDef="quantity">
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell justify-content-end"
|
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||||
i18n
|
i18n
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
mat-sort-header
|
mat-sort-header
|
||||||
>
|
>
|
||||||
Quantity
|
Quantity
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="d-none d-lg-table-cell" mat-cell>
|
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
[isCurrency]="true"
|
[isCurrency]="true"
|
||||||
@ -123,14 +135,14 @@
|
|||||||
<ng-container matColumnDef="unitPrice">
|
<ng-container matColumnDef="unitPrice">
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell justify-content-end"
|
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||||
i18n
|
i18n
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
mat-sort-header
|
mat-sort-header
|
||||||
>
|
>
|
||||||
Unit Price
|
Unit Price
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="d-none d-lg-table-cell" mat-cell>
|
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
[isCurrency]="true"
|
[isCurrency]="true"
|
||||||
@ -144,14 +156,14 @@
|
|||||||
<ng-container matColumnDef="fee">
|
<ng-container matColumnDef="fee">
|
||||||
<th
|
<th
|
||||||
*matHeaderCellDef
|
*matHeaderCellDef
|
||||||
class="d-none d-lg-table-cell justify-content-end"
|
class="d-none d-lg-table-cell justify-content-end px-1"
|
||||||
i18n
|
i18n
|
||||||
mat-header-cell
|
mat-header-cell
|
||||||
mat-sort-header
|
mat-sort-header
|
||||||
>
|
>
|
||||||
Fee
|
Fee
|
||||||
</th>
|
</th>
|
||||||
<td class="d-none d-lg-table-cell" mat-cell *matCellDef="let element">
|
<td *matCellDef="let element" class="d-none d-lg-table-cell px1" mat-cell>
|
||||||
<div class="d-flex justify-content-end">
|
<div class="d-flex justify-content-end">
|
||||||
<gf-value
|
<gf-value
|
||||||
[isCurrency]="true"
|
[isCurrency]="true"
|
||||||
@ -162,9 +174,24 @@
|
|||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="account">
|
||||||
|
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Account</th>
|
||||||
|
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||||
|
<div class="d-flex">
|
||||||
|
<gf-symbol-icon
|
||||||
|
*ngIf="element.Account?.Platform?.url"
|
||||||
|
class="mr-1"
|
||||||
|
[tooltip]="element.Account?.Platform?.name"
|
||||||
|
[url]="element.Account?.Platform?.url"
|
||||||
|
></gf-symbol-icon>
|
||||||
|
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="actions">
|
<ng-container matColumnDef="actions">
|
||||||
<th *matHeaderCellDef class="px-0 text-center" i18n mat-header-cell></th>
|
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
|
||||||
<td *matCellDef="let element" class="px-0 text-center" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||||
<button
|
<button
|
||||||
class="mx-1 no-min-width px-2"
|
class="mx-1 no-min-width px-2"
|
||||||
mat-button
|
mat-button
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
@ -7,11 +9,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mat-table {
|
.mat-chip {
|
||||||
td {
|
cursor: pointer;
|
||||||
border: 0;
|
min-height: 1.5rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mat-table {
|
||||||
th {
|
th {
|
||||||
::ng-deep {
|
::ng-deep {
|
||||||
.mat-sort-header-container {
|
.mat-sort-header-container {
|
||||||
@ -23,13 +26,6 @@
|
|||||||
.mat-row {
|
.mat-row {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:nth-child(even) {
|
|
||||||
background-color: rgba(
|
|
||||||
var(--dark-primary-text),
|
|
||||||
var(--palette-background-hover-alpha)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-badge {
|
.type-badge {
|
||||||
background-color: rgba(var(--dark-primary-text), 0.05);
|
background-color: rgba(var(--dark-primary-text), 0.05);
|
||||||
border-radius: 1rem;
|
border-radius: 1rem;
|
||||||
@ -57,17 +53,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mat-table {
|
.mat-table {
|
||||||
.mat-row {
|
.type-badge {
|
||||||
&:nth-child(even) {
|
background-color: rgba(var(--light-primary-text), 0.1);
|
||||||
background-color: rgba(
|
|
||||||
var(--light-primary-text),
|
|
||||||
var(--palette-background-hover-alpha)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-badge {
|
|
||||||
background-color: rgba(var(--light-primary-text), 0.1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
|
import { COMMA, ENTER } from '@angular/cdk/keycodes';
|
||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
ElementRef,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
OnChanges,
|
OnChanges,
|
||||||
@ -9,17 +11,27 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
ViewChild
|
ViewChild
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { FormControl } from '@angular/forms';
|
||||||
|
import {
|
||||||
|
MatAutocomplete,
|
||||||
|
MatAutocompleteSelectedEvent
|
||||||
|
} from '@angular/material/autocomplete';
|
||||||
|
import { MatChipInputEvent } from '@angular/material/chips';
|
||||||
import { MatDialog } from '@angular/material/dialog';
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
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 { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper';
|
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||||
import { Order as OrderModel } from '@prisma/client';
|
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||||
import { Subject, Subscription } from 'rxjs';
|
import { format } from 'date-fns';
|
||||||
|
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
|
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
|
||||||
|
|
||||||
|
const SEARCH_PLACEHOLDER = 'Search for account, currency, symbol or type...';
|
||||||
|
const SEARCH_STRING_SEPARATOR = ',';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'gf-transactions-table',
|
selector: 'gf-transactions-table',
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
@ -32,20 +44,29 @@ export class TransactionsTableComponent
|
|||||||
@Input() deviceType: string;
|
@Input() deviceType: string;
|
||||||
@Input() locale: string;
|
@Input() locale: string;
|
||||||
@Input() showActions: boolean;
|
@Input() showActions: boolean;
|
||||||
@Input() transactions: OrderModel[];
|
@Input() transactions: OrderWithAccount[];
|
||||||
|
|
||||||
@Output() transactionDeleted = new EventEmitter<string>();
|
@Output() transactionDeleted = new EventEmitter<string>();
|
||||||
@Output() transactionToClone = new EventEmitter<OrderModel>();
|
@Output() transactionToClone = new EventEmitter<OrderWithAccount>();
|
||||||
@Output() transactionToUpdate = new EventEmitter<OrderModel>();
|
@Output() transactionToUpdate = new EventEmitter<OrderWithAccount>();
|
||||||
|
|
||||||
|
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
|
||||||
|
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
|
||||||
@ViewChild(MatSort) sort: MatSort;
|
@ViewChild(MatSort) sort: MatSort;
|
||||||
|
|
||||||
public dataSource: MatTableDataSource<OrderModel> = new MatTableDataSource();
|
public dataSource: MatTableDataSource<OrderWithAccount> = new MatTableDataSource();
|
||||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||||
public displayedColumns = [];
|
public displayedColumns = [];
|
||||||
|
public filters$: Subject<string[]> = new BehaviorSubject([]);
|
||||||
|
public filters: Observable<string[]> = this.filters$.asObservable();
|
||||||
public isLoading = true;
|
public isLoading = true;
|
||||||
|
public placeholder = '';
|
||||||
public routeQueryParams: Subscription;
|
public routeQueryParams: Subscription;
|
||||||
|
public searchControl = new FormControl();
|
||||||
|
public searchKeywords: string[] = [];
|
||||||
|
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||||
|
|
||||||
|
private allFilters: string[];
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
@ -63,20 +84,63 @@ export class TransactionsTableComponent
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.searchControl.valueChanges.subscribe((keyword) => {
|
||||||
|
if (keyword) {
|
||||||
|
const filterValue = keyword.toLowerCase();
|
||||||
|
this.filters$.next(
|
||||||
|
this.allFilters.filter(
|
||||||
|
(filter) => filter.toLowerCase().indexOf(filterValue) === 0
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.filters$.next(this.allFilters);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public addKeyword({ input, value }: MatChipInputEvent): void {
|
||||||
|
if (value?.trim()) {
|
||||||
|
this.searchKeywords.push(value.trim());
|
||||||
|
this.updateFilter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the input value
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchControl.setValue(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeKeyword(keyword: string): void {
|
||||||
|
const index = this.searchKeywords.indexOf(keyword);
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
this.searchKeywords.splice(index, 1);
|
||||||
|
this.updateFilter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public keywordSelected(event: MatAutocompleteSelectedEvent): void {
|
||||||
|
this.searchKeywords.push(event.option.viewValue);
|
||||||
|
this.updateFilter();
|
||||||
|
this.searchInput.nativeElement.value = '';
|
||||||
|
this.searchControl.setValue(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
this.displayedColumns = [
|
this.displayedColumns = [
|
||||||
'account',
|
|
||||||
'date',
|
'date',
|
||||||
'type',
|
'type',
|
||||||
'symbol',
|
'symbol',
|
||||||
'currency',
|
'currency',
|
||||||
'quantity',
|
'quantity',
|
||||||
'unitPrice',
|
'unitPrice',
|
||||||
'fee'
|
'fee',
|
||||||
|
'account'
|
||||||
];
|
];
|
||||||
|
|
||||||
if (this.showActions) {
|
if (this.showActions) {
|
||||||
@ -87,17 +151,23 @@ export class TransactionsTableComponent
|
|||||||
|
|
||||||
if (this.transactions) {
|
if (this.transactions) {
|
||||||
this.dataSource = new MatTableDataSource(this.transactions);
|
this.dataSource = new MatTableDataSource(this.transactions);
|
||||||
|
this.dataSource.filterPredicate = (data, filter) => {
|
||||||
|
const dataString = this.getFilterableValues(data)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
let contains = true;
|
||||||
|
for (const singleFilter of filter.split(SEARCH_STRING_SEPARATOR)) {
|
||||||
|
contains =
|
||||||
|
contains && dataString.includes(singleFilter.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
return contains;
|
||||||
|
};
|
||||||
this.dataSource.sort = this.sort;
|
this.dataSource.sort = this.sort;
|
||||||
|
this.updateFilter();
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyFilter(event: Event) {
|
|
||||||
const filterValue = (event.target as HTMLInputElement).value;
|
|
||||||
this.dataSource.filter = filterValue.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
public onDeleteTransaction(aId: string) {
|
public onDeleteTransaction(aId: string) {
|
||||||
const confirmation = confirm(
|
const confirmation = confirm(
|
||||||
'Do you really want to delete this transaction?'
|
'Do you really want to delete this transaction?'
|
||||||
@ -120,11 +190,11 @@ export class TransactionsTableComponent
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public onUpdateTransaction(aTransaction: OrderModel) {
|
public onUpdateTransaction(aTransaction: OrderWithAccount) {
|
||||||
this.transactionToUpdate.emit(aTransaction);
|
this.transactionToUpdate.emit(aTransaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
public onCloneTransaction(aTransaction: OrderModel) {
|
public onCloneTransaction(aTransaction: OrderWithAccount) {
|
||||||
this.transactionToClone.emit(aTransaction);
|
this.transactionToClone.emit(aTransaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -157,4 +227,66 @@ export class TransactionsTableComponent
|
|||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private updateFilter() {
|
||||||
|
this.dataSource.filter = this.searchKeywords.join(SEARCH_STRING_SEPARATOR);
|
||||||
|
const lowercaseSearchKeywords = this.searchKeywords.map((keyword) =>
|
||||||
|
keyword.trim().toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
this.placeholder =
|
||||||
|
lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : '';
|
||||||
|
|
||||||
|
this.allFilters = this.getSearchableFieldValues(this.transactions).filter(
|
||||||
|
(item) => {
|
||||||
|
return !lowercaseSearchKeywords.includes(item.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.filters$.next(this.allFilters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getSearchableFieldValues(transactions: OrderWithAccount[]): string[] {
|
||||||
|
const fieldValues = new Set<string>();
|
||||||
|
|
||||||
|
for (const transaction of transactions) {
|
||||||
|
this.getFilterableValues(transaction, fieldValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...fieldValues]
|
||||||
|
.filter((item) => {
|
||||||
|
return item !== undefined;
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aFirstChar = a.charAt(0);
|
||||||
|
const bFirstChar = b.charAt(0);
|
||||||
|
const isANumber = aFirstChar >= '0' && aFirstChar <= '9';
|
||||||
|
const isBNumber = bFirstChar >= '0' && bFirstChar <= '9';
|
||||||
|
|
||||||
|
// Sort priority: text, followed by numbers
|
||||||
|
if (isANumber && !isBNumber) {
|
||||||
|
return 1;
|
||||||
|
} else if (!isANumber && isBNumber) {
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
return a.toLowerCase() < b.toLowerCase() ? -1 : 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFilterableValues(
|
||||||
|
transaction: OrderWithAccount,
|
||||||
|
fieldValues: Set<string> = new Set<string>()
|
||||||
|
): string[] {
|
||||||
|
fieldValues.add(transaction.currency);
|
||||||
|
fieldValues.add(transaction.symbol);
|
||||||
|
fieldValues.add(transaction.type);
|
||||||
|
fieldValues.add(transaction.Account?.name);
|
||||||
|
fieldValues.add(transaction.Account?.Platform?.name);
|
||||||
|
fieldValues.add(format(transaction.date, 'yyyy'));
|
||||||
|
|
||||||
|
return [...fieldValues].filter((item) => {
|
||||||
|
return item !== undefined;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,9 @@
|
|||||||
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 { ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatChipsModule } from '@angular/material/chips';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
import { MatSortModule } from '@angular/material/sort';
|
import { MatSortModule } from '@angular/material/sort';
|
||||||
@ -23,12 +26,15 @@ import { TransactionsTableComponent } from './transactions-table.component';
|
|||||||
GfSymbolIconModule,
|
GfSymbolIconModule,
|
||||||
GfSymbolModule,
|
GfSymbolModule,
|
||||||
GfValueModule,
|
GfValueModule,
|
||||||
|
MatAutocompleteModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
MatChipsModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatMenuModule,
|
MatMenuModule,
|
||||||
MatSortModule,
|
MatSortModule,
|
||||||
MatTableModule,
|
MatTableModule,
|
||||||
NgxSkeletonLoaderModule,
|
NgxSkeletonLoaderModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
RouterModule
|
RouterModule
|
||||||
],
|
],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
@ -5,7 +5,7 @@ import {
|
|||||||
OnChanges,
|
OnChanges,
|
||||||
OnInit
|
OnInit
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/helper';
|
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||||
import { format, isDate } from 'date-fns';
|
import { format, isDate } from 'date-fns';
|
||||||
import { isNumber } from 'lodash';
|
import { isNumber } from 'lodash';
|
||||||
|
|
||||||
|
@ -5,34 +5,79 @@ import {
|
|||||||
Router,
|
Router,
|
||||||
RouterStateSnapshot
|
RouterStateSnapshot
|
||||||
} from '@angular/router';
|
} from '@angular/router';
|
||||||
|
import { ViewMode } from '@prisma/client';
|
||||||
|
import { EMPTY } from 'rxjs';
|
||||||
|
import { catchError } from 'rxjs/operators';
|
||||||
|
|
||||||
import { TokenStorageService } from '../services/token-storage.service';
|
import { SettingsStorageService } from '../services/settings-storage.service';
|
||||||
|
import { UserService } from '../services/user/user.service';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
export class AuthGuard implements CanActivate {
|
export class AuthGuard implements CanActivate {
|
||||||
|
private static PUBLIC_PAGE_ROUTES = [
|
||||||
|
'/about',
|
||||||
|
'/pricing',
|
||||||
|
'/register',
|
||||||
|
'/resources'
|
||||||
|
];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private router: Router,
|
private router: Router,
|
||||||
private tokenStorageService: TokenStorageService
|
private settingsStorageService: SettingsStorageService,
|
||||||
|
private userService: UserService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
|
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
|
||||||
const isLoggedIn = !!this.tokenStorageService.getToken();
|
if (route.queryParams?.utm_source) {
|
||||||
|
this.settingsStorageService.setSetting(
|
||||||
if (isLoggedIn) {
|
'utm_source',
|
||||||
if (state.url === '/start') {
|
route.queryParams?.utm_source
|
||||||
this.router.navigate(['/home']);
|
);
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not logged in
|
return new Promise<boolean>((resolve) => {
|
||||||
if (state.url !== '/start') {
|
this.userService
|
||||||
this.router.navigate(['/start']);
|
.get()
|
||||||
return false;
|
.pipe(
|
||||||
}
|
catchError(() => {
|
||||||
|
if (AuthGuard.PUBLIC_PAGE_ROUTES.includes(state.url)) {
|
||||||
|
resolve(true);
|
||||||
|
return EMPTY;
|
||||||
|
} else if (state.url !== '/start') {
|
||||||
|
this.router.navigate(['/start']);
|
||||||
|
resolve(false);
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
resolve(true);
|
||||||
|
return EMPTY;
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe((user) => {
|
||||||
|
if (
|
||||||
|
state.url.startsWith('/home') &&
|
||||||
|
user.settings.viewMode === ViewMode.ZEN
|
||||||
|
) {
|
||||||
|
this.router.navigate(['/zen']);
|
||||||
|
resolve(false);
|
||||||
|
} else if (state.url.startsWith('/start')) {
|
||||||
|
if (user.settings.viewMode === ViewMode.ZEN) {
|
||||||
|
this.router.navigate(['/zen']);
|
||||||
|
} else {
|
||||||
|
this.router.navigate(['/home']);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(false);
|
||||||
|
} else if (
|
||||||
|
state.url.startsWith('/zen') &&
|
||||||
|
user.settings.viewMode === ViewMode.DEFAULT
|
||||||
|
) {
|
||||||
|
this.router.navigate(['/home']);
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,8 +1,4 @@
|
|||||||
import {
|
import { HTTP_INTERCEPTORS, HttpEvent } from '@angular/common/http';
|
||||||
HTTP_INTERCEPTORS,
|
|
||||||
HttpErrorResponse,
|
|
||||||
HttpEvent
|
|
||||||
} from '@angular/common/http';
|
|
||||||
import {
|
import {
|
||||||
HttpHandler,
|
HttpHandler,
|
||||||
HttpInterceptor,
|
HttpInterceptor,
|
||||||
@ -11,7 +7,6 @@ import {
|
|||||||
import { Injectable } from '@angular/core';
|
import { Injectable } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Observable } from 'rxjs';
|
import { Observable } from 'rxjs';
|
||||||
import { tap } from 'rxjs/operators';
|
|
||||||
|
|
||||||
import { ImpersonationStorageService } from '../services/impersonation-storage.service';
|
import { ImpersonationStorageService } from '../services/impersonation-storage.service';
|
||||||
import { TokenStorageService } from '../services/token-storage.service';
|
import { TokenStorageService } from '../services/token-storage.service';
|
||||||
|
@ -79,7 +79,6 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
|||||||
}
|
}
|
||||||
} else if (error.status === StatusCodes.UNAUTHORIZED) {
|
} else if (error.status === StatusCodes.UNAUTHORIZED) {
|
||||||
this.tokenStorageService.signOut();
|
this.tokenStorageService.signOut();
|
||||||
this.router.navigate(['start']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return throwError('');
|
return throwError('');
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user