Compare commits

..

1 Commits

Author SHA1 Message Date
b23f3a8a81 Release 0.93.0 2021-04-26 21:55:51 +02:00
314 changed files with 5842 additions and 12249 deletions

4
.env
View File

@ -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://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer DATABASE_URL=postgresql://user:password@localhost:5432/ghostfolio-db?sslmode=prefer
JWT_SECRET_KEY=123456 JWT_SECRET_KEY=123456
PORT=3333 PORT=3333

View File

@ -1,11 +0,0 @@
language: node_js
git:
depth: false
node_js:
- 14
before_script:
- yarn
script:
- yarn format:check
- yarn test
- yarn build:all

View File

@ -5,390 +5,11 @@ 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.27.0 - 18.07.2021
### Changed
- Improved the onboarding
- Flow of creating a new account
- Info message to add the first transaction
### Fixed
- Fixed the chart on the landing page
- Fixed the url to the _Fear & Greed Index_ on the resources page
## 1.26.0 - 17.07.2021
### Added
- Added the import functionality for transactions
- Added the `robots.txt` file
### Changed
- Improved the styling of the current pricing plan
- Improved the styling of the transaction type badge
- Set the public _Stripe_ key dynamically
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
### Fixed
- Fixed the warn color (button) of the theme
## 1.25.0 - 11.07.2021
### Added
- Added the export functionality for transactions
### Changed
- Respected the cash balance on the analysis page
- Improved the settings selectors on the account page
- Harmonized the slogan to "Open Source Wealth Management Software"
### Fixed
- Fixed rendering of currency and platform in dialogs (account and transaction)
- Fixed an issue in the calculation of the average buy prices in the position detail chart
## 1.24.0 - 07.07.2021
### Added
- Added the total value in the create or edit transaction dialog
- Added a balance attribute to the account model
- Calculated the total balance (cash)
### Changed
- Upgraded `@angular/cdk` and `@angular/material` from version `11.0.4` to `12.0.6`
- Upgraded `@nestjs` dependencies
- Upgraded `angular-material-css-vars` from version `1.2.0` to `2.0.0`
- Upgraded `Nx` from version `12.3.6` to `12.5.4`
## 1.23.1 - 03.07.2021
### Fixed
- Fixed the investment chart (drafts)
## 1.23.0 - 03.07.2021
### Added
- Added support for future transactions (drafts)
## 1.22.0 - 25.06.2021
### Added
- Set the user id in the _Stripe_ callback
## 1.21.0 - 22.06.2021
### Changed
- Changed _Stripe_ mode from `subscription` to `payment`
### Fixed
- Fixed the base currency on the pricing page
## 1.20.0 - 21.06.2021
### Added
- Set up _Stripe_ for subscriptions
### Changed
- Improved the style of the _Ghostfolio in Numbers_ section
## 1.19.0 - 17.06.2021
### Added
- Added a _Ghostfolio in Numbers_ section to the about page
## 1.18.0 - 16.06.2021
### Changed
- Improved the pie chart: Investments by sector
- Improved the onboarding for TWA by redirecting to the account registration page
## 1.17.0 - 15.06.2021
### Changed
- Improved the error page of the sign in with fingerprint
- Disable the sign in with fingerprint selector for the demo user
- Upgraded `angular` from version `11.2.4` to `12.0.4`
- Upgraded `angular-material-css-vars` from version `1.1.2` to `1.2.0`
- Upgraded `chart.js` from version `3.2.1` to `3.3.2`
- Upgraded `date-fns` from version `2.19.0` to `2.22.1`
- Upgraded `eslint` and `prettier` dependencies
- Upgraded `ngx-device-detector` from version `2.0.6` to `2.1.1`
- Upgraded `ngx-markdown` from version `11.1.2` to `12.0.1`
## 1.16.0 - 14.06.2021
### Changed
- Improved the sign in with fingerprint
## 1.15.0 - 14.06.2021
### Added
- Added a counter column to the transactions table
- Added a label to indicate the default account in the accounts table
- Added an option to limit the items in pie charts
- Added sign in with fingerprint
### Changed
- Cleaned up the analysis page with an unused chart module
- Improved the cell alignment in the users table of the admin control panel
### Fixed
- Fixed the last activity column of users in the admin control panel
## 1.14.0 - 09.06.2021
### Added
- Added a connect or create symbol profile model logic on creating a new transaction
### Changed
- Improved the global heat map to visualize investments by country
## 1.13.0 - 08.06.2021
### Added
- Added a global heat map to visualize investments by country
## 1.12.0 - 06.06.2021
### Added
- Added a symbol profile model with additional data
- Added new pie charts: Investments 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 users table of the admin control panel
### Changed
- Improved the alignment in the users 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
### Added
- Added the functionality to clone a transaction
- Added a _Google Play_ badge on the landing page
### Changed
- Changed to maskable icons
## 0.99.0 - 03.05.2021
### Added
- Added support for deleting users in the admin control panel
### Changed
- Eliminated the platform attribute from the transaction model
## 0.98.0 - 02.05.2021
### Added
- Added the logic to create and update accounts
## 0.97.0 - 01.05.2021
### Added
- Added an account page as a preparation for the multi accounts support
## 0.96.0 - 30.04.2021
### Added
- Added the absolute change to the position detail dialog
- Added the number of transactions to the position detail dialog
### Changed
- Harmonized the slogan to "Open Source Portfolio Tracker"
## 0.95.0 - 28.04.2021
### Added
- Added a data source attribute to the transactions model
## 0.94.0 - 27.04.2021
### Added
- Added the generic scraper symbols to the symbol lookup results
## 0.93.0 - 26.04.2021 ## 0.93.0 - 26.04.2021
### Changed ### Changed
- Improved the users table styling of the admin control panel - Improved the user table styling of the admin control panel
- Improved the background colors in the dark mode - Improved the background colors in the dark mode
## 0.92.0 - 25.04.2021 ## 0.92.0 - 25.04.2021
@ -396,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Prepared further for multi accounts support: store account for new transactions - Prepared further for multi accounts support: store account for new transactions
- Added a horizontal scrollbar to the users table of the admin control panel - Added a horizontal scrollbar to the user table of the admin control panel
### Fixed ### Fixed
@ -423,7 +44,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the users table of the admin control panel - Improved the user table of the admin control panel
## 0.89.0 - 21.04.2021 ## 0.89.0 - 21.04.2021
@ -454,7 +75,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed ### Fixed
- Fixed an issue in the users table of the admin control panel with missing data - Fixed an issue in the user table of the admin control panel with missing data
## 0.86.1 - 18.04.2021 ## 0.86.1 - 18.04.2021
@ -469,7 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Changed the about page for the new license - Changed the about page for the new license
- Optimized the data management for historical data - Optimized the data management for historical data
- Optimized the exchange rate service - Optimized the exchange rate service
- Improved the users table of the admin control panel - Improved the user table of the admin control panel
### Fixed ### Fixed

View File

@ -1,36 +1,19 @@
<div align="center"> <div align="center">
<a href="https://ghostfol.io">
<img
alt="Ghostfolio Logo"
src="https://avatars.githubusercontent.com/u/82473144?s=200"
width="100"
/>
</a>
<h1>Ghostfolio</h1> <h1>Ghostfolio</h1>
<p> <p>
<strong>Open Source Wealth Management Software made for Humans</strong> <strong>Open Source Portfolio Tracker</strong>
</p> </p>
<p> <p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> <a href="https://ghostfol.io"><strong>Live Demo</strong></a>
</p> </p>
<p> <p>
<a href="#contributing">
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.com/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"/></a> <img src="https://img.shields.io/badge/License-AGPL%20v3-blue.svg" alt="License: AGPL v3">
</a>
</p> </p>
</div> </div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. **Ghostfolio** is an open source 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 Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
## Why Ghostfolio? ## Why Ghostfolio?
@ -57,13 +40,10 @@ 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
@ -71,11 +51,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 together with [Prisma](https://www.prisma.io) 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 and [Redis](https://redis.io) for caching.
### Frontend ### Frontend
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com). The frontend is built with [Angular](https://angular.io).
## Getting Started ## Getting Started
@ -88,40 +68,33 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
### Setup ### Setup
1. Run `yarn install` 1. Run `yarn install`
1. Run `cd docker` 2. Run `cd docker`
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) 3. Run `docker compose build`
1. Run `cd -` to go back to the project root directory 4. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data 5. Run `cd -` to go back to the project root directory
1. Start server and client (see [_Development_](#Development)) 6. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9` 7. Start server and client (see _Development_)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data 8. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Click _Sign out_ and check out the _Live Demo_ 9. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
10. Press _Sign out_ and check out the _Live Demo_
## Development ## Development
Please make sure you have completed the instructions from [_Setup_](#Setup). Please make sure you have completed the instructions from _Setup_
### Start server ### Start server
<ol type="a"> - Debug: Run `yarn watch:server` and click "Launch Program" in _Visual Studio Code_
<li>Debug: Run <code>yarn watch:server</code> and click "Launch Program" in <i>Visual Studio Code</i></li> - Serve: Run `yarn start:server`
<li>Serve: Run <code>yarn start:server</code></li>
</ol>
### Start client ### Start client
Run `yarn start:client` - Run `yarn start:client`
## Testing ## Testing
Run `yarn test` Run `yarn test`
## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
## License ## License
© 2021 [Ghostfolio](https://ghostfol.io) © 2021 [Ghostfolio](https://ghostfol.io)

View File

@ -86,6 +86,7 @@
"main": "apps/client/src/main.ts", "main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts", "polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json", "tsConfig": "apps/client/tsconfig.app.json",
"aot": true,
"assets": [ "assets": [
"apps/client/src/assets", "apps/client/src/assets",
{ {
@ -103,11 +104,6 @@
"input": "", "input": "",
"output": "./" "output": "./"
}, },
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./"
},
{ {
"glob": "sitemap.xml", "glob": "sitemap.xml",
"input": "apps/client/src/assets", "input": "apps/client/src/assets",
@ -125,13 +121,7 @@
} }
], ],
"styles": ["apps/client/src/styles.scss"], "styles": ["apps/client/src/styles.scss"],
"scripts": ["node_modules/marked/lib/marked.js"], "scripts": ["node_modules/marked/lib/marked.js"]
"vendorChunk": true,
"extractLicenses": false,
"buildOptimizer": false,
"sourceMap": true,
"optimization": false,
"namedChunks": true
}, },
"configurations": { "configurations": {
"production": { "production": {
@ -162,8 +152,7 @@
] ]
} }
}, },
"outputs": ["{options.outputPath}"], "outputs": ["{options.outputPath}"]
"defaultConfiguration": ""
}, },
"serve": { "serve": {
"builder": "@angular-devkit/build-angular:dev-server", "builder": "@angular-devkit/build-angular:dev-server",
@ -219,22 +208,22 @@
} }
} }
}, },
"common": { "helper": {
"root": "libs/common", "root": "libs/helper",
"sourceRoot": "libs/common/src", "sourceRoot": "libs/helper/src",
"projectType": "library", "projectType": "library",
"architect": { "architect": {
"lint": { "lint": {
"builder": "@nrwl/linter:eslint", "builder": "@nrwl/linter:eslint",
"options": { "options": {
"lintFilePatterns": ["libs/common/**/*.ts"] "lintFilePatterns": ["libs/helper/**/*.ts"]
} }
}, },
"test": { "test": {
"builder": "@nrwl/jest:jest", "builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/common"], "outputs": ["coverage/libs/helper"],
"options": { "options": {
"jestConfig": "libs/common/jest.config.js", "jestConfig": "libs/helper/jest.config.js",
"passWithNoTests": true "passWithNoTests": true
} }
} }

View File

@ -11,6 +11,5 @@ module.exports = {
}, },
moduleFileExtensions: ['ts', 'js', 'html'], moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api', coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000, testTimeout: 10000
testEnvironment: 'node'
}; };

View File

@ -1,10 +1,10 @@
import { Access } from '@ghostfolio/common/interfaces'; import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
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 {

View File

@ -1,8 +1,9 @@
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) {}

View File

@ -1,241 +0,0 @@
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
Post,
Put,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Account as AccountModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AccountService } from './account.service';
import { CreateAccountDto } from './create-account.dto';
import { UpdateAccountDto } from './update-account.dto';
@Controller('account')
export class AccountController {
public constructor(
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteAccount
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const account = await this.accountService.accountWithOrders(
{
id_userId: {
id,
userId: this.request.user.id
}
},
{ Order: true }
);
if (account?.isDefault || account?.Order.length > 0) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.accountService.deleteAccount(
{
id_userId: {
id,
userId: this.request.user.id
}
},
this.request.user.id
);
}
@Get()
@UseGuards(AuthGuard('jwt'))
public async getAllAccounts(
@Headers('impersonation-id') impersonationId
): Promise<AccountModel[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let accounts = await this.accountService.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: impersonationUserId || this.request.user.id }
});
if (
impersonationUserId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
accounts = nullifyValuesInObjects(accounts, [
'fee',
'quantity',
'unitPrice'
]);
}
return accounts;
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
public async getAccountById(@Param('id') id: string): Promise<AccountModel> {
return this.accountService.account({
id_userId: {
id,
userId: this.request.user.id
}
});
}
@Post()
@UseGuards(AuthGuard('jwt'))
public async createAccount(
@Body() data: CreateAccountDto
): Promise<AccountModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.createAccount
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
return this.accountService.createAccount(
{
...data,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
} else {
delete data.platformId;
return this.accountService.createAccount(
{
...data,
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
}
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.updateAccount
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalAccount = await this.accountService.account({
id_userId: {
id,
userId: this.request.user.id
}
});
if (!originalAccount) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (data.platformId) {
const platformId = data.platformId;
delete data.platformId;
return this.accountService.updateAccount(
{
data: {
...data,
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
} else {
// platformId is null, remove it
delete data.platformId;
return this.accountService.updateAccount(
{
data: {
...data,
Platform: originalAccount.platformId
? { disconnect: true }
: undefined,
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
}
}
}

View File

@ -1,32 +0,0 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { AccountController } from './account.controller';
import { AccountService } from './account.service';
@Module({
imports: [RedisCacheModule],
controllers: [AccountController],
providers: [
AccountService,
AlphaVantageService,
ConfigurationService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
ImpersonationService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class AccountModule {}

View File

@ -1,113 +0,0 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Account, Currency, Order, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CashDetails } from './interfaces/cash-details.interface';
@Injectable()
export class AccountService {
public constructor(
private exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService
) {}
public async account(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
): Promise<Account | null> {
return this.prisma.account.findUnique({
where: accountWhereUniqueInput
});
}
public async accountWithOrders(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput,
accountInclude: Prisma.AccountInclude
): Promise<
Account & {
Order?: Order[];
}
> {
return this.prisma.account.findUnique({
include: accountInclude,
where: accountWhereUniqueInput
});
}
public async accounts(params: {
include?: Prisma.AccountInclude;
skip?: number;
take?: number;
cursor?: Prisma.AccountWhereUniqueInput;
where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByInput;
}): Promise<Account[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.account.findMany({
cursor,
include,
orderBy,
skip,
take,
where
});
}
public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string
): Promise<Account> {
return this.prisma.account.create({
data
});
}
public async deleteAccount(
where: Prisma.AccountWhereUniqueInput,
aUserId: string
): Promise<Account> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
return this.prisma.account.delete({
where
});
}
public async getCashDetails(
aUserId: string,
aCurrency: Currency
): Promise<CashDetails> {
let totalCashBalance = 0;
const accounts = await this.accounts({
where: { userId: aUserId }
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
);
});
return { accounts, balance: totalCashBalance };
}
public async updateAccount(
params: {
where: Prisma.AccountWhereUniqueInput;
data: Prisma.AccountUpdateInput;
},
aUserId: string
): Promise<Account> {
const { data, where } = params;
return this.prisma.account.update({
data,
where
});
}
}

View File

@ -1,20 +0,0 @@
import { AccountType, Currency } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString()
name: string;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
}

View File

@ -1,6 +0,0 @@
import { Account } from '@prisma/client';
export interface CashDetails {
accounts: Account[];
balance: number;
}

View File

@ -1,23 +0,0 @@
import { AccountType, Currency } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString()
id: string;
@IsString()
name: string;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
}

View File

@ -1,11 +1,6 @@
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 { AdminData } 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 {
Controller, Controller,
Get, Get,
@ -19,6 +14,7 @@ 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 {

View File

@ -1,9 +1,10 @@
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(
@ -108,7 +109,7 @@ export class AdminService {
createdAt: true, createdAt: true,
id: true id: true
}, },
take: 30, take: 20,
where: { where: {
NOT: { NOT: {
Analytics: null Analytics: null

View File

@ -1,6 +1,5 @@
import { join } from 'path'; import { join } from 'path';
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
@ -17,19 +16,15 @@ import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yah
import { ExchangeRateDataService } from '../services/exchange-rate-data.service'; import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { PrismaService } from '../services/prisma.service'; import { PrismaService } from '../services/prisma.service';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExperimentalModule } from './experimental/experimental.module'; import { ExperimentalModule } from './experimental/experimental.module';
import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module'; import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@ -37,14 +32,10 @@ import { UserModule } from './user/user.module';
imports: [ imports: [
AdminModule, AdminModule,
AccessModule, AccessModule,
AccountModule,
AuthDeviceModule,
AuthModule, AuthModule,
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
ExperimentalModule, ExperimentalModule,
ExportModule,
ImportModule,
InfoModule, InfoModule,
OrderModule, OrderModule,
PortfolioModule, PortfolioModule,
@ -64,7 +55,6 @@ import { UserModule } from './user/user.module';
rootPath: join(__dirname, '..', 'client'), rootPath: join(__dirname, '..', 'client'),
exclude: ['/api*'] exclude: ['/api*']
}), }),
SubscriptionModule,
SymbolModule, SymbolModule,
UserModule UserModule
], ],

View File

@ -1,44 +0,0 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Delete,
HttpException,
Inject,
Param,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Controller('auth-device')
export class AuthDeviceController {
public constructor(
private readonly authDeviceService: AuthDeviceService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteAuthDevice
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
await this.authDeviceService.deleteAuthDevice({ id });
}
}

View File

@ -1,4 +0,0 @@
export interface AuthDeviceDto {
createdAt: string;
id: string;
}

View File

@ -1,18 +0,0 @@
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@Module({
controllers: [AuthDeviceController],
imports: [
JwtModule.register({
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' }
})
],
providers: [AuthDeviceService, ConfigurationService, PrismaService]
})
export class AuthDeviceModule {}

View File

@ -1,65 +0,0 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { AuthDevice, Prisma } from '@prisma/client';
@Injectable()
export class AuthDeviceService {
public constructor(
private readonly configurationService: ConfigurationService,
private prisma: PrismaService
) {}
public async authDevice(
where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice | null> {
return this.prisma.authDevice.findUnique({
where
});
}
public async authDevices(params: {
skip?: number;
take?: number;
cursor?: Prisma.AuthDeviceWhereUniqueInput;
where?: Prisma.AuthDeviceWhereInput;
orderBy?: Prisma.AuthDeviceOrderByInput;
}): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.authDevice.findMany({
skip,
take,
cursor,
where,
orderBy
});
}
public async createAuthDevice(
data: Prisma.AuthDeviceCreateInput
): Promise<AuthDevice> {
return this.prisma.authDevice.create({
data
});
}
public async updateAuthDevice(params: {
data: Prisma.AuthDeviceUpdateInput;
where: Prisma.AuthDeviceWhereUniqueInput;
}): Promise<AuthDevice> {
const { data, where } = params;
return this.prisma.authDevice.update({
data,
where
});
}
public async deleteAuthDevice(
where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice> {
return this.prisma.authDevice.delete({
where
});
}
}

View File

@ -1,12 +1,9 @@
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { import {
Body,
Controller, Controller,
Get, Get,
HttpException, HttpException,
Param, Param,
Post,
Req, Req,
Res, Res,
UseGuards UseGuards
@ -15,17 +12,12 @@ import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Controller('auth') @Controller('auth')
export class AuthController { export class AuthController {
public constructor( public constructor(
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService
private readonly webAuthService: WebAuthService
) {} ) {}
@Get('anonymous/:accessToken') @Get('anonymous/:accessToken')
@ -61,44 +53,4 @@ export class AuthController {
res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`); res.redirect(`${this.configurationService.get('ROOT_URL')}/auth`);
} }
} }
@Get('webauthn/generate-attestation-options')
@UseGuards(AuthGuard('jwt'))
public async generateAttestationOptions() {
return this.webAuthService.generateAttestationOptions();
}
@Post('webauthn/verify-attestation')
@UseGuards(AuthGuard('jwt'))
public async verifyAttestation(
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
) {
return this.webAuthService.verifyAttestation(
body.deviceName,
body.credential
);
}
@Post('webauthn/generate-assertion-options')
public async generateAssertionOptions(@Body() body: { deviceId: string }) {
return this.webAuthService.generateAssertionOptions(body.deviceId);
}
@Post('webauthn/verify-assertion')
public async verifyAssertion(
@Body() body: { deviceId: string; credential: AssertionCredentialJSON }
) {
try {
const authToken = await this.webAuthService.verifyAssertion(
body.deviceId,
body.credential
);
return { authToken };
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
}
} }

View File

@ -1,5 +1,3 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
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 { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -20,14 +18,12 @@ import { JwtStrategy } from './jwt.strategy';
}) })
], ],
providers: [ providers: [
AuthDeviceService,
AuthService, AuthService,
ConfigurationService, ConfigurationService,
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
PrismaService, PrismaService,
UserService, UserService
WebAuthService
] ]
}) })
export class AuthModule {} export class AuthModule {}

View File

@ -1,10 +1,5 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
export interface AuthDeviceDialogParams {
authDevice: AuthDeviceDto;
}
export interface ValidateOAuthLoginParams { export interface ValidateOAuthLoginParams {
provider: Provider; provider: Provider;
thirdPartyId: string; thirdPartyId: string;

View File

@ -1,226 +0,0 @@
export interface AuthenticatorAssertionResponse extends AuthenticatorResponse {
readonly authenticatorData: ArrayBuffer;
readonly signature: ArrayBuffer;
readonly userHandle: ArrayBuffer | null;
}
export interface AuthenticatorAttestationResponse
extends AuthenticatorResponse {
readonly attestationObject: ArrayBuffer;
}
export interface AuthenticationExtensionsClientInputs {
appid?: string;
appidExclude?: string;
credProps?: boolean;
uvm?: boolean;
}
export interface AuthenticationExtensionsClientOutputs {
appid?: boolean;
credProps?: CredentialPropertiesOutput;
uvm?: UvmEntries;
}
export interface AuthenticatorSelectionCriteria {
authenticatorAttachment?: AuthenticatorAttachment;
requireResidentKey?: boolean;
residentKey?: ResidentKeyRequirement;
userVerification?: UserVerificationRequirement;
}
export interface PublicKeyCredential extends Credential {
readonly rawId: ArrayBuffer;
readonly response: AuthenticatorResponse;
getClientExtensionResults(): AuthenticationExtensionsClientOutputs;
}
export interface PublicKeyCredentialCreationOptions {
attestation?: AttestationConveyancePreference;
authenticatorSelection?: AuthenticatorSelectionCriteria;
challenge: BufferSource;
excludeCredentials?: PublicKeyCredentialDescriptor[];
extensions?: AuthenticationExtensionsClientInputs;
pubKeyCredParams: PublicKeyCredentialParameters[];
rp: PublicKeyCredentialRpEntity;
timeout?: number;
user: PublicKeyCredentialUserEntity;
}
export interface PublicKeyCredentialDescriptor {
id: BufferSource;
transports?: AuthenticatorTransport[];
type: PublicKeyCredentialType;
}
export interface PublicKeyCredentialParameters {
alg: COSEAlgorithmIdentifier;
type: PublicKeyCredentialType;
}
export interface PublicKeyCredentialRequestOptions {
allowCredentials?: PublicKeyCredentialDescriptor[];
challenge: BufferSource;
extensions?: AuthenticationExtensionsClientInputs;
rpId?: string;
timeout?: number;
userVerification?: UserVerificationRequirement;
}
export interface PublicKeyCredentialUserEntity
extends PublicKeyCredentialEntity {
displayName: string;
id: BufferSource;
}
export interface AuthenticatorResponse {
readonly clientDataJSON: ArrayBuffer;
}
export interface CredentialPropertiesOutput {
rk?: boolean;
}
export interface Credential {
readonly id: string;
readonly type: string;
}
export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity {
id?: string;
}
export interface PublicKeyCredentialEntity {
name: string;
}
export declare type AttestationConveyancePreference =
| 'direct'
| 'enterprise'
| 'indirect'
| 'none';
export declare type AuthenticatorTransport = 'ble' | 'internal' | 'nfc' | 'usb';
export declare type COSEAlgorithmIdentifier = number;
export declare type UserVerificationRequirement =
| 'discouraged'
| 'preferred'
| 'required';
export declare type UvmEntries = UvmEntry[];
export declare type AuthenticatorAttachment = 'cross-platform' | 'platform';
export declare type ResidentKeyRequirement =
| 'discouraged'
| 'preferred'
| 'required';
export declare type BufferSource = ArrayBufferView | ArrayBuffer;
export declare type PublicKeyCredentialType = 'public-key';
export declare type UvmEntry = number[];
export interface PublicKeyCredentialCreationOptionsJSON
extends Omit<
PublicKeyCredentialCreationOptions,
'challenge' | 'user' | 'excludeCredentials'
> {
user: PublicKeyCredentialUserEntityJSON;
challenge: Base64URLString;
excludeCredentials: PublicKeyCredentialDescriptorJSON[];
extensions?: AuthenticationExtensionsClientInputs;
}
/**
* A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to
* (eventually) get passed into navigator.credentials.get(...) in the browser.
*/
export interface PublicKeyCredentialRequestOptionsJSON
extends Omit<
PublicKeyCredentialRequestOptions,
'challenge' | 'allowCredentials'
> {
challenge: Base64URLString;
allowCredentials?: PublicKeyCredentialDescriptorJSON[];
extensions?: AuthenticationExtensionsClientInputs;
}
export interface PublicKeyCredentialDescriptorJSON
extends Omit<PublicKeyCredentialDescriptor, 'id'> {
id: Base64URLString;
}
export interface PublicKeyCredentialUserEntityJSON
extends Omit<PublicKeyCredentialUserEntity, 'id'> {
id: string;
}
/**
* The value returned from navigator.credentials.create()
*/
export interface AttestationCredential extends PublicKeyCredential {
response: AuthenticatorAttestationResponseFuture;
}
/**
* A slightly-modified AttestationCredential to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AttestationCredentialJSON
extends Omit<
AttestationCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
rawId: Base64URLString;
response: AuthenticatorAttestationResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
transports?: AuthenticatorTransport[];
}
/**
* The value returned from navigator.credentials.get()
*/
export interface AssertionCredential extends PublicKeyCredential {
response: AuthenticatorAssertionResponse;
}
/**
* A slightly-modified AssertionCredential to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AssertionCredentialJSON
extends Omit<
AssertionCredential,
'response' | 'rawId' | 'getClientExtensionResults'
> {
rawId: Base64URLString;
response: AuthenticatorAssertionResponseJSON;
clientExtensionResults: AuthenticationExtensionsClientOutputs;
}
/**
* A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AuthenticatorAttestationResponseJSON
extends Omit<
AuthenticatorAttestationResponseFuture,
'clientDataJSON' | 'attestationObject'
> {
clientDataJSON: Base64URLString;
attestationObject: Base64URLString;
}
/**
* A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that
* are Base64URL-encoded in the browser so that they can be sent as JSON to the server.
*/
export interface AuthenticatorAssertionResponseJSON
extends Omit<
AuthenticatorAssertionResponse,
'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle'
> {
authenticatorData: Base64URLString;
clientDataJSON: Base64URLString;
signature: Base64URLString;
userHandle?: string;
}
/**
* A WebAuthn-compatible device and the information needed to verify assertions by it
*/
export declare type AuthenticatorDevice = {
credentialPublicKey: Buffer;
credentialID: Buffer;
counter: number;
transports?: AuthenticatorTransport[];
};
/**
* An attempt to communicate that this isn't just any string, but a Base64URL-encoded string
*/
export declare type Base64URLString = string;
/**
* AuthenticatorAttestationResponse in TypeScript's DOM lib is outdated (up through v3.9.7).
* Maintain an augmented version here so we can implement additional properties as the WebAuthn
* spec evolves.
*
* See https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse
*
* Properties marked optional are not supported in all browsers.
*/
export interface AuthenticatorAttestationResponseFuture
extends AuthenticatorAttestationResponse {
getTransports?: () => AuthenticatorTransport[];
getAuthenticatorData?: () => ArrayBuffer;
getPublicKey?: () => ArrayBuffer;
getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[];
}

View File

@ -1,216 +0,0 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Inject,
Injectable,
InternalServerErrorException
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
import {
GenerateAssertionOptionsOpts,
GenerateAttestationOptionsOpts,
VerifiedAssertion,
VerifiedAttestation,
VerifyAssertionResponseOpts,
VerifyAttestationResponseOpts,
generateAssertionOptions,
generateAttestationOptions,
verifyAssertionResponse,
verifyAttestationResponse
} from '@simplewebauthn/server';
import { UserService } from '../user/user.service';
import {
AssertionCredentialJSON,
AttestationCredentialJSON
} from './interfaces/simplewebauthn';
@Injectable()
export class WebAuthService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly deviceService: AuthDeviceService,
private readonly jwtService: JwtService,
private readonly userService: UserService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
get rpID() {
return this.configurationService.get('WEB_AUTH_RP_ID');
}
get expectedOrigin() {
return this.configurationService.get('ROOT_URL');
}
public async generateAttestationOptions() {
const user = this.request.user;
const opts: GenerateAttestationOptionsOpts = {
rpName: 'Ghostfolio',
rpID: this.rpID,
userID: user.id,
userName: user.alias,
timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: {
authenticatorAttachment: 'platform',
requireResidentKey: false,
userVerification: 'required'
}
};
const options = generateAttestationOptions(opts);
await this.userService.updateUser({
data: {
authChallenge: options.challenge
},
where: {
id: user.id
}
});
return options;
}
public async verifyAttestation(
deviceName: string,
credential: AttestationCredentialJSON
): Promise<AuthDeviceDto> {
const user = this.request.user;
const expectedChallenge = user.authChallenge;
let verification: VerifiedAttestation;
try {
const opts: VerifyAttestationResponseOpts = {
credential,
expectedChallenge,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
};
verification = await verifyAttestationResponse(opts);
} catch (error) {
console.error(error);
throw new InternalServerErrorException(error.message);
}
const { verified, attestationInfo } = verification;
const devices = await this.deviceService.authDevices({
where: { userId: user.id }
});
if (verified && attestationInfo) {
const { credentialPublicKey, credentialID, counter } = attestationInfo;
let existingDevice = devices.find(
(device) => device.credentialId === credentialID
);
if (!existingDevice) {
/**
* Add the returned device to the user's list of devices
*/
existingDevice = await this.deviceService.createAuthDevice({
credentialPublicKey,
credentialId: credentialID,
counter,
User: { connect: { id: user.id } }
});
}
return {
createdAt: existingDevice.createdAt.toISOString(),
id: existingDevice.id
};
}
throw new InternalServerErrorException('An unknown error occurred');
}
public async generateAssertionOptions(deviceId: string) {
const device = await this.deviceService.authDevice({ id: deviceId });
if (!device) {
throw new Error('Device not found');
}
const opts: GenerateAssertionOptionsOpts = {
timeout: 60000,
allowCredentials: [
{
id: device.credentialId,
type: 'public-key',
transports: ['internal']
}
],
userVerification: 'preferred',
rpID: this.rpID
};
const options = generateAssertionOptions(opts);
await this.userService.updateUser({
data: {
authChallenge: options.challenge
},
where: {
id: device.userId
}
});
return options;
}
public async verifyAssertion(
deviceId: string,
credential: AssertionCredentialJSON
) {
const device = await this.deviceService.authDevice({ id: deviceId });
if (!device) {
throw new Error('Device not found');
}
const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAssertion;
try {
const opts: VerifyAssertionResponseOpts = {
credential,
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
authenticator: {
credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey,
counter: device.counter
}
};
verification = verifyAssertionResponse(opts);
} catch (error) {
console.error(error);
throw new InternalServerErrorException({ error: error.message });
}
const { verified, assertionInfo } = verification;
if (verified) {
device.counter = assertionInfo.newCounter;
await this.deviceService.updateAuthDevice({
data: device,
where: { id: device.id }
});
return this.jwtService.sign({
id: user.id
});
}
throw new Error();
}
}

View File

@ -1,5 +1,5 @@
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; import { Controller, Inject, Param, 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';

View File

@ -1,6 +1,9 @@
import { baseCurrency, benchmarks } from '@ghostfolio/common/config'; import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions'; import {
import { RequestWithUser } from '@ghostfolio/common/types'; baseCurrency,
benchmarks,
isApiTokenAuthorized
} from '@ghostfolio/helper';
import { import {
Body, Body,
Controller, Controller,
@ -37,9 +40,7 @@ export class ExperimentalController {
); );
} }
return benchmarks.map(({ symbol }) => { return benchmarks;
return symbol;
});
} }
@Get('benchmarks/:symbol') @Get('benchmarks/:symbol')

View File

@ -1,5 +1,3 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -15,10 +13,9 @@ import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service'; import { ExperimentalService } from './experimental.service';
@Module({ @Module({
imports: [RedisCacheModule], imports: [],
controllers: [ExperimentalController], controllers: [ExperimentalController],
providers: [ providers: [
AccountService,
AlphaVantageService, AlphaVantageService,
ConfigurationService, ConfigurationService,
DataProviderService, DataProviderService,

View File

@ -1,21 +1,19 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
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 { 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 { OrderWithPlatform } from '../order/interfaces/order-with-platform.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';
@Injectable() @Injectable()
export class ExperimentalService { export class ExperimentalService {
public constructor( public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private prisma: PrismaService, private prisma: PrismaService,
@ -35,18 +33,16 @@ export class ExperimentalService {
aDate: Date, aDate: Date,
aBaseCurrency: Currency aBaseCurrency: Currency
): Promise<Data> { ): Promise<Data> {
const ordersWithPlatform: OrderWithAccount[] = aOrders.map((order) => { const ordersWithPlatform: OrderWithPlatform[] = aOrders.map((order) => {
return { return {
...order, ...order,
accountId: undefined, accountId: undefined,
accountUserId: undefined, accountUserId: undefined,
createdAt: new Date(), createdAt: new Date(),
dataSource: undefined,
date: parseISO(order.date), date: parseISO(order.date),
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
@ -54,7 +50,6 @@ export class ExperimentalService {
}); });
const portfolio = new Portfolio( const portfolio = new Portfolio(
this.accountService,
this.dataProviderService, this.dataProviderService,
this.exchangeRateDataService, this.exchangeRateDataService,
this.rulesService this.rulesService

View File

@ -1,23 +0,0 @@
import { Export } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { ExportService } from './export.service';
@Controller('export')
export class ExportController {
public constructor(
private readonly exportService: ExportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async export(): Promise<Export> {
return await this.exportService.export({
userId: this.request.user.id
});
}
}

View File

@ -1,32 +0,0 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [RedisCacheModule],
controllers: [ExportController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExportService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class ExportModule {}

View File

@ -1,31 +0,0 @@
import { environment } from '@ghostfolio/api/environments/environment';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExportService {
public constructor(private prisma: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> {
const orders = await this.prisma.order.findMany({
orderBy: { date: 'desc' },
select: {
currency: true,
dataSource: true,
date: true,
fee: true,
quantity: true,
symbol: true,
type: true,
unitPrice: true
},
where: { userId }
});
return {
meta: { date: new Date().toISOString(), version: environment.version },
orders
};
}
}

View File

@ -1,7 +0,0 @@
import { Order } from '@prisma/client';
import { IsArray } from 'class-validator';
export class ImportDataDto {
@IsArray()
orders: Partial<Order>[];
}

View File

@ -1,50 +0,0 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ImportDataDto } from './import-data.dto';
import { ImportService } from './import.service';
@Controller('import')
export class ImportController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Post()
@UseGuards(AuthGuard('jwt'))
public async import(@Body() importData: ImportDataDto): Promise<void> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
return await this.importService.import({
orders: importData.orders,
userId: this.request.user.id
});
} catch (error) {
console.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

View File

@ -1,34 +0,0 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ImportController } from './import.controller';
import { ImportService } from './import.service';
@Module({
imports: [RedisCacheModule],
controllers: [ImportController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
ImportService,
OrderService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class ImportModule {}

View File

@ -1,43 +0,0 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client';
import { parseISO } from 'date-fns';
@Injectable()
export class ImportService {
public constructor(private readonly orderService: OrderService) {}
public async import({
orders,
userId
}: {
orders: Partial<Order>[];
userId: string;
}): Promise<void> {
for (const {
currency,
dataSource,
date,
fee,
quantity,
symbol,
type,
unitPrice
} of orders) {
await this.orderService.createOrder(
{
currency,
dataSource,
fee,
quantity,
symbol,
type,
unitPrice,
date: parseISO(<string>(<unknown>date)),
User: { connect: { id: userId } }
},
userId
);
}
}
}

View File

@ -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 {

View File

@ -1,13 +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 { InfoItem } from '@ghostfolio/common/interfaces'; import { permissions } from '@ghostfolio/helper';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
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 * as bent from 'bent';
import { subDays } from 'date-fns'; import { InfoItem } from './interfaces/info-item.interface';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
@ -20,7 +18,6 @@ export class InfoService {
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
const platforms = await this.prisma.platform.findMany({ const platforms = await this.prisma.platform.findMany({
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { id: true, name: true } select: { id: true, name: true }
@ -28,83 +25,23 @@ export class InfoService {
const globalPermissions: string[] = []; const globalPermissions: string[] = [];
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) { if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin); globalPermissions.push(permissions.enableSocialLogin);
} }
if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
globalPermissions.push(permissions.enableStatistics);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
globalPermissions.push(permissions.enableSubscription); globalPermissions.push(permissions.enableSubscription);
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
} }
return { return {
...info,
globalPermissions, globalPermissions,
platforms, platforms,
currencies: Object.values(Currency), currencies: Object.values(Currency),
demoAuthToken: this.getDemoAuthToken(), demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering()
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions()
}; };
} }
private async countActiveUsers(aDays: number) {
return await this.prisma.user.count({
orderBy: {
Analytics: {
updatedAt: 'desc'
}
},
where: {
AND: [
{
NOT: {
Analytics: null
}
},
{
Analytics: {
updatedAt: {
gt: subDays(new Date(), aDays)
}
}
}
]
}
});
}
private async countGitHubStargazers(): Promise<number> {
try {
const get = bent(
`https://api.github.com/repos/ghostfolio/ghostfolio`,
'GET',
'json',
200,
{
'User-Agent': 'request'
}
);
const { stargazers_count } = await get();
return stargazers_count;
} catch (error) {
console.error(error);
return undefined;
}
}
private getDemoAuthToken() { private getDemoAuthToken() {
return this.jwtService.sign({ return this.jwtService.sign({
id: InfoService.DEMO_USER_ID id: InfoService.DEMO_USER_ID
@ -118,36 +55,4 @@ export class InfoService {
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null; return lastDataGathering?.value ? new Date(lastDataGathering.value) : null;
} }
private async getStatistics() {
if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) {
return undefined;
}
const activeUsers1d = await this.countActiveUsers(1);
const activeUsers30d = await this.countActiveUsers(30);
const gitHubStargazers = await this.countGitHubStargazers();
return {
activeUsers1d,
activeUsers30d,
gitHubStargazers
};
}
private async getSubscriptions(): Promise<Subscription[]> {
if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
return undefined;
}
const stripeConfig = await this.prisma.property.findUnique({
where: { key: 'STRIPE_CONFIG' }
});
if (stripeConfig) {
return [JSON.parse(stripeConfig.value)];
}
return [];
}
} }

View File

@ -1,8 +1,5 @@
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { Statistics } from './statistics.interface';
import { Subscription } from './subscription.interface';
export interface InfoItem { export interface InfoItem {
currencies: Currency[]; currencies: Currency[];
demoAuthToken: string; demoAuthToken: string;
@ -13,7 +10,4 @@ export interface InfoItem {
type: string; type: string;
}; };
platforms: { id: string; name: string }[]; platforms: { id: string; name: string }[];
statistics: Statistics;
stripePublicKey?: string;
subscriptions: Subscription[];
} }

View File

@ -1,3 +1,3 @@
import { UserWithSettings } from '@ghostfolio/common/interfaces'; import { UserWithSettings } from './user-with-settings';
export type RequestWithUser = Request & { user: UserWithSettings }; export type RequestWithUser = Request & { user: UserWithSettings };

View File

@ -0,0 +1,6 @@
import { Account, Settings, User } from '@prisma/client';
export type UserWithSettings = User & {
Account: Account[];
Settings: Settings;
};

View File

@ -1,5 +1,5 @@
import { Currency, DataSource, Type } from '@prisma/client'; import { Currency, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString } from 'class-validator'; import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateOrderDto { export class CreateOrderDto {
@IsString() @IsString()
@ -8,15 +8,16 @@ export class CreateOrderDto {
@IsString() @IsString()
currency: Currency; currency: Currency;
@IsString()
dataSource: DataSource;
@IsISO8601() @IsISO8601()
date: string; date: string;
@IsNumber() @IsNumber()
fee: number; fee: number;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
@IsNumber() @IsNumber()
quantity: number; quantity: number;

View File

@ -0,0 +1,3 @@
import { Order, Platform } from '@prisma/client';
export type OrderWithPlatform = Order & { Platform?: Platform };

View File

@ -1,11 +1,7 @@
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 { import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -68,19 +64,14 @@ export class OrderController {
public async getAllOrders( public async getAllOrders(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> { ): Promise<OrderModel[]> {
const impersonationUserId = const impersonationUserId = await this.impersonationService.validateImpersonationId(
await this.impersonationService.validateImpersonationId( impersonationId,
impersonationId, this.request.user.id
this.request.user.id );
);
let orders = await this.orderService.orders({ let orders = await this.orderService.orders({
include: { include: {
Account: { Platform: true
include: {
Platform: true
}
}
}, },
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
where: { userId: impersonationUserId || this.request.user.id } where: { userId: impersonationUserId || this.request.user.id }
@ -130,33 +121,41 @@ export class OrderController {
const accountId = data.accountId; const accountId = data.accountId;
delete data.accountId; delete data.accountId;
return this.orderService.createOrder( if (data.platformId) {
{ const platformId = data.platformId;
...data, delete data.platformId;
Account: {
connect: { return this.orderService.createOrder(
id_userId: { id: accountId, userId: this.request.user.id } {
} ...data,
}, date,
date, Account: {
SymbolProfile: { connect: {
connectOrCreate: { id_userId: { id: accountId, userId: this.request.user.id }
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
} }
} },
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
}, },
User: { connect: { id: this.request.user.id } } this.request.user.id
}, );
this.request.user.id } else {
); delete data.platformId;
return this.orderService.createOrder(
{
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
}
} }
@Put(':id') @Put(':id')
@ -181,38 +180,65 @@ export class OrderController {
} }
}); });
if (!originalOrder) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const date = parseISO(data.date); const date = parseISO(data.date);
const accountId = data.accountId; const accountId = data.accountId;
delete data.accountId; delete data.accountId;
return this.orderService.updateOrder( if (data.platformId) {
{ const platformId = data.platformId;
data: { delete data.platformId;
...data,
date, return this.orderService.updateOrder(
Account: { {
connect: { data: {
id_userId: { id: accountId, userId: this.request.user.id } ...data,
} date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
Platform: { connect: { id: platformId } },
User: { connect: { id: this.request.user.id } }
}, },
User: { connect: { id: this.request.user.id } } where: {
}, id_userId: {
where: { id,
id_userId: { userId: this.request.user.id
id, }
userId: this.request.user.id
} }
} },
}, this.request.user.id
this.request.user.id );
); } else {
// platformId is null, remove it
delete data.platformId;
return this.orderService.updateOrder(
{
data: {
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
Platform: originalOrder.platformId
? { disconnect: true }
: undefined,
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
},
this.request.user.id
);
}
} }
} }

View File

@ -1,12 +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 { DataSource, Order, Prisma } from '@prisma/client'; import { Order, Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
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 { OrderWithPlatform } from './interfaces/order-with-platform.type';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
@ -32,7 +31,7 @@ export class OrderService {
cursor?: Prisma.OrderWhereUniqueInput; cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput; where?: Prisma.OrderWhereInput;
orderBy?: Prisma.OrderOrderByInput; orderBy?: Prisma.OrderOrderByInput;
}): Promise<OrderWithAccount[]> { }): Promise<OrderWithPlatform[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.order.findMany({ return this.prisma.order.findMany({
@ -51,16 +50,13 @@ export class OrderService {
): Promise<Order> { ): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`); this.redisCacheService.remove(`${aUserId}.portfolio`);
if (!isAfter(data.date as Date, endOfToday())) { // Gather symbol data of order in the background
// Gather symbol data of order in the background, if not draft this.dataGatheringService.gatherSymbols([
this.dataGatheringService.gatherSymbols([ {
{ date: <Date>data.date,
dataSource: data.dataSource, symbol: data.symbol
date: <Date>data.date, }
symbol: data.symbol ]);
}
]);
}
await this.cacheService.flush(aUserId); await this.cacheService.flush(aUserId);
@ -94,7 +90,6 @@ 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
} }

View File

@ -1,4 +1,4 @@
import { Currency, DataSource, Type } from '@prisma/client'; import { Currency, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator'; import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateOrderDto { export class UpdateOrderDto {
@ -8,15 +8,16 @@ export class UpdateOrderDto {
@IsString() @IsString()
currency: Currency; currency: Currency;
@IsString()
dataSource: DataSource;
@IsISO8601() @IsISO8601()
date: string; date: string;
@IsNumber() @IsNumber()
fee: number; fee: number;
@IsString()
@ValidateIf((object, value) => value !== null)
platformId: string | null;
@IsString() @IsString()
id: string; id: string;

View File

@ -1,5 +1,13 @@
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
export interface PortfolioItem {
date: string;
grossPerformancePercent: number;
investment: number;
positions: { [symbol: string]: Position };
value: number;
}
export interface Position { export interface Position {
averagePrice: number; averagePrice: number;
currency: Currency; currency: Currency;
@ -8,5 +16,4 @@ export interface Position {
investmentInOriginalCurrency?: number; investmentInOriginalCurrency?: number;
marketPrice?: number; marketPrice?: number;
quantity: number; quantity: number;
transactionCount: number;
} }

View File

@ -1,5 +1,4 @@
export interface PortfolioOverview { export interface PortfolioOverview {
cash: number;
committedFunds: number; committedFunds: number;
fees: number; fees: number;
ordersCount: number; ordersCount: number;

View File

@ -1,8 +1,6 @@
import { Currency } from '@prisma/client';
export interface PortfolioPositionDetail { export interface PortfolioPositionDetail {
averagePrice: number; averagePrice: number;
currency: Currency; currency: string;
firstBuyDate: string; firstBuyDate: string;
grossPerformance: number; grossPerformance: number;
grossPerformancePercent: number; grossPerformancePercent: number;
@ -13,7 +11,6 @@ export interface PortfolioPositionDetail {
minPrice: number; minPrice: number;
quantity: number; quantity: number;
symbol: string; symbol: string;
transactionCount: number;
} }
export interface HistoricalDataItem { export interface HistoricalDataItem {

View File

@ -1,31 +1,26 @@
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { Country } from './country.interface';
import { Sector } from './sector.interface';
export interface PortfolioPosition { export interface PortfolioPosition {
accounts: {
[name: string]: { current: number; original: number };
};
allocationCurrent: number;
allocationInvestment: number;
countries: Country[];
currency: Currency; currency: Currency;
exchange?: string; exchange?: string;
grossPerformance: number; grossPerformance: number;
grossPerformancePercent: number; grossPerformancePercent: number;
industry?: string;
investment: number; investment: number;
marketChange?: number; marketChange?: number;
marketChangePercent?: number; marketChangePercent?: number;
marketPrice: number; marketPrice: number;
marketState: MarketState; marketState: MarketState;
name: string; name: string;
platforms: {
[name: string]: { current: number; original: number };
};
quantity: number; quantity: number;
sectors: Sector[]; sector?: string;
transactionCount: number; shareCurrent: number;
shareInvestment: number;
symbol: string; symbol: string;
type?: string; type?: string;
url?: string; url?: string;
value: number;
} }

View File

@ -1,3 +1,7 @@
export interface PortfolioReport {
rules: { [group: string]: PortfolioReportRule[] };
}
export interface PortfolioReportRule { export interface PortfolioReportRule {
evaluation: string; evaluation: string;
name: string; name: string;

View File

@ -4,19 +4,7 @@ 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 { import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
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,
@ -33,10 +21,16 @@ 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')
@ -142,11 +136,10 @@ export class PortfolioController {
): Promise<{ [symbol: string]: PortfolioPosition }> { ): Promise<{ [symbol: string]: PortfolioPosition }> {
let details: { [symbol: string]: PortfolioPosition } = {}; let details: { [symbol: string]: PortfolioPosition } = {};
const impersonationUserId = const impersonationUserId = await this.impersonationService.validateImpersonationId(
await this.impersonationService.validateImpersonationId( impersonationId,
impersonationId, this.request.user.id
this.request.user.id );
);
const portfolio = await this.portfolioService.createPortfolio( const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id impersonationUserId || this.request.user.id
@ -192,11 +185,11 @@ export class PortfolioController {
portfolioPosition.investment = portfolioPosition.investment =
portfolioPosition.investment / totalInvestment; portfolioPosition.investment / totalInvestment;
for (const [account, { current, original }] of Object.entries( for (const [platform, { current, original }] of Object.entries(
portfolioPosition.accounts portfolioPosition.platforms
)) { )) {
portfolioPosition.accounts[account].current = current / totalValue; portfolioPosition.platforms[platform].current = current / totalValue;
portfolioPosition.accounts[account].original = portfolioPosition.platforms[platform].original =
original / totalInvestment; original / totalInvestment;
} }
@ -222,7 +215,6 @@ export class PortfolioController {
) )
) { ) {
overview = nullifyValuesInObject(overview, [ overview = nullifyValuesInObject(overview, [
'cash',
'committedFunds', 'committedFunds',
'fees', 'fees',
'totalBuy', 'totalBuy',
@ -240,11 +232,10 @@ export class PortfolioController {
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioPerformance> { ): Promise<PortfolioPerformance> {
const impersonationUserId = const impersonationUserId = await this.impersonationService.validateImpersonationId(
await this.impersonationService.validateImpersonationId( impersonationId,
impersonationId, this.request.user.id
this.request.user.id );
);
const portfolio = await this.portfolioService.createPortfolio( const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id impersonationUserId || this.request.user.id
@ -309,16 +300,27 @@ export class PortfolioController {
public async getReport( public async getReport(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<PortfolioReport> { ): Promise<PortfolioReport> {
const impersonationUserId = const impersonationUserId = await this.impersonationService.validateImpersonationId(
await this.impersonationService.validateImpersonationId( impersonationId,
impersonationId, this.request.user.id
this.request.user.id );
);
const portfolio = await this.portfolioService.createPortfolio( const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id impersonationUserId || this.request.user.id
); );
return await portfolio.getReport(); let report = await portfolio.getReport();
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
// TODO: Filter out absolute numbers
}
return report;
} }
} }

View File

@ -1,8 +1,3 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; 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';
@ -16,6 +11,10 @@ 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 { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CacheService } from '../cache/cache.service';
import { OrderService } from '../order/order.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { UserService } from '../user/user.service';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
@ -23,7 +22,6 @@ import { PortfolioService } from './portfolio.service';
imports: [RedisCacheModule], imports: [RedisCacheModule],
controllers: [PortfolioController], controllers: [PortfolioController],
providers: [ providers: [
AccountService,
AlphaVantageService, AlphaVantageService,
CacheService, CacheService,
ConfigurationService, ConfigurationService,

View File

@ -1,32 +1,20 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
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,
addMonths,
endOfToday,
format, format,
getDate, getDate,
getMonth, getMonth,
getYear, getYear,
isAfter, isAfter,
isSameDay, isSameDay,
parse,
parseISO, parseISO,
setDate, setDate,
setMonth, setMonth,
@ -35,6 +23,12 @@ import {
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import * as roundTo from 'round-to'; import * as roundTo from 'round-to';
import { OrderService } from '../order/order.service';
import { RedisCacheService } from '../redis-cache/redis-cache.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
@ -43,7 +37,6 @@ import {
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
public constructor( public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
@ -56,7 +49,7 @@ export class PortfolioService {
public async createPortfolio(aUserId: string): Promise<Portfolio> { public async createPortfolio(aUserId: string): Promise<Portfolio> {
let portfolio: Portfolio; let portfolio: Portfolio;
const stringifiedPortfolio = await this.redisCacheService.get( let stringifiedPortfolio = await this.redisCacheService.get(
`${aUserId}.portfolio` `${aUserId}.portfolio`
); );
@ -67,11 +60,11 @@ export class PortfolioService {
const { const {
orders, orders,
portfolioItems portfolioItems
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } = }: { orders: IOrder[]; portfolioItems: PortfolioItem[] } = JSON.parse(
JSON.parse(stringifiedPortfolio); stringifiedPortfolio
);
portfolio = new Portfolio( portfolio = new Portfolio(
this.accountService,
this.dataProviderService, this.dataProviderService,
this.exchangeRateDataService, this.exchangeRateDataService,
this.rulesService this.rulesService
@ -80,15 +73,13 @@ 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, Platform: true
SymbolProfile: true
}, },
orderBy: { date: 'asc' }, orderBy: { date: 'asc' },
where: { userId: aUserId } where: { userId: aUserId }
}); });
portfolio = new Portfolio( portfolio = new Portfolio(
this.accountService,
this.dataProviderService, this.dataProviderService,
this.exchangeRateDataService, this.exchangeRateDataService,
this.rulesService this.rulesService
@ -109,21 +100,15 @@ export class PortfolioService {
} }
// Enrich portfolio with current data // Enrich portfolio with current data
await portfolio.addCurrentPortfolioItems(); return await portfolio.addCurrentPortfolioItems();
// Enrich portfolio with future data
await portfolio.addFuturePortfolioItems();
return portfolio;
} }
public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> { public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> {
try { try {
const impersonationUserId = const impersonationUserId = await this.impersonationService.validateImpersonationId(
await this.impersonationService.validateImpersonationId( aImpersonationId,
aImpersonationId, this.request.user.id
this.request.user.id );
);
const portfolio = await this.createPortfolio( const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id impersonationUserId || this.request.user.id
@ -138,11 +123,10 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> { ): Promise<HistoricalDataItem[]> {
const impersonationUserId = const impersonationUserId = await this.impersonationService.validateImpersonationId(
await this.impersonationService.validateImpersonationId( aImpersonationId,
aImpersonationId, this.request.user.id
this.request.user.id );
);
const portfolio = await this.createPortfolio( const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id impersonationUserId || this.request.user.id
@ -160,11 +144,6 @@ export class PortfolioService {
return portfolio return portfolio
.get() .get()
.filter((portfolioItem) => { .filter((portfolioItem) => {
if (isAfter(parseISO(portfolioItem.date), endOfToday())) {
// Filter out future dates
return false;
}
if (dateRangeDate === undefined) { if (dateRangeDate === undefined) {
return true; return true;
} }
@ -178,8 +157,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 - portfolioItem.investment ?? null value: portfolioItem.value || null
}; };
}); });
} }
@ -187,27 +166,21 @@ export class PortfolioService {
public async getOverview( public async getOverview(
aImpersonationId: string aImpersonationId: string
): Promise<PortfolioOverview> { ): Promise<PortfolioOverview> {
const impersonationUserId = const impersonationUserId = await this.impersonationService.validateImpersonationId(
await this.impersonationService.validateImpersonationId( aImpersonationId,
aImpersonationId, this.request.user.id
this.request.user.id );
);
const portfolio = await this.createPortfolio( const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id impersonationUserId || this.request.user.id
); );
const { balance } = await this.accountService.getCashDetails(
impersonationUserId || this.request.user.id,
this.request.user.Settings.currency
);
const committedFunds = portfolio.getCommittedFunds(); const committedFunds = portfolio.getCommittedFunds();
const fees = portfolio.getFees(); const fees = portfolio.getFees();
return { return {
committedFunds, committedFunds,
fees, fees,
cash: balance,
ordersCount: portfolio.getOrders().length, ordersCount: portfolio.getOrders().length,
totalBuy: portfolio.getTotalBuy(), totalBuy: portfolio.getTotalBuy(),
totalSell: portfolio.getTotalSell() totalSell: portfolio.getTotalSell()
@ -218,29 +191,26 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aSymbol: string aSymbol: string
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
const impersonationUserId = const impersonationUserId = await this.impersonationService.validateImpersonationId(
await this.impersonationService.validateImpersonationId( aImpersonationId,
aImpersonationId, this.request.user.id
this.request.user.id );
);
const portfolio = await this.createPortfolio( const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id impersonationUserId || this.request.user.id
); );
const position = portfolio.getPositions(new Date())[aSymbol]; const positions = portfolio.getPositions(new Date())[aSymbol];
if (position) { if (positions) {
const { let {
averagePrice, averagePrice,
currency, currency,
firstBuyDate, firstBuyDate,
investment, investment,
quantity, marketPrice,
transactionCount quantity
} = position; } = portfolio.getPositions(new Date())[aSymbol];
let marketPrice = position.marketPrice;
const orders = portfolio.getOrders(aSymbol);
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
[aSymbol], [aSymbol],
@ -254,7 +224,6 @@ export class PortfolioService {
} }
const historicalDataArray: HistoricalDataItem[] = []; const historicalDataArray: HistoricalDataItem[] = [];
let currentAveragePrice: number;
let maxPrice = marketPrice; let maxPrice = marketPrice;
let minPrice = marketPrice; let minPrice = marketPrice;
@ -262,25 +231,9 @@ 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 next month
const snapshot = portfolio.get(
addMonths(setDate(currentDate, 1), 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
}); });
@ -309,7 +262,6 @@ export class PortfolioService {
maxPrice, maxPrice,
minPrice, minPrice,
quantity, quantity,
transactionCount,
grossPerformance: this.exchangeRateDataService.toCurrency( grossPerformance: this.exchangeRateDataService.toCurrency(
marketPrice - averagePrice, marketPrice - averagePrice,
currency, currency,
@ -334,7 +286,7 @@ export class PortfolioService {
if (isEmpty(historicalData)) { if (isEmpty(historicalData)) {
historicalData = await this.dataProviderService.getHistoricalRaw( historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }], [aSymbol],
portfolio.getMinDate(), portfolio.getMinDate(),
new Date() new Date()
); );
@ -342,7 +294,7 @@ export class PortfolioService {
const historicalDataArray: HistoricalDataItem[] = []; const historicalDataArray: HistoricalDataItem[] = [];
for (const [date, { marketPrice }] of Object.entries( for (const [date, { marketPrice, performance }] of Object.entries(
historicalData[aSymbol] historicalData[aSymbol]
).reverse()) { ).reverse()) {
historicalDataArray.push({ historicalDataArray.push({
@ -353,18 +305,17 @@ export class PortfolioService {
return { return {
averagePrice: undefined, averagePrice: undefined,
currency: currentData[aSymbol]?.currency, currency: currentData[aSymbol].currency,
firstBuyDate: undefined, firstBuyDate: undefined,
grossPerformance: undefined, grossPerformance: undefined,
grossPerformancePercent: undefined, grossPerformancePercent: undefined,
historicalData: historicalDataArray, historicalData: historicalDataArray,
investment: undefined, investment: undefined,
marketPrice: currentData[aSymbol]?.marketPrice, marketPrice: currentData[aSymbol].marketPrice,
maxPrice: undefined, maxPrice: undefined,
minPrice: undefined, minPrice: undefined,
quantity: undefined, quantity: undefined,
symbol: aSymbol, symbol: aSymbol
transactionCount: undefined
}; };
} }
@ -380,8 +331,7 @@ export class PortfolioService {
maxPrice: undefined, maxPrice: undefined,
minPrice: undefined, minPrice: undefined,
quantity: undefined, quantity: undefined,
symbol: aSymbol, symbol: aSymbol
transactionCount: undefined
}; };
} }

View File

@ -1,57 +0,0 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
HttpException,
Inject,
Post,
Req,
Res,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service';
@Controller('subscription')
export class SubscriptionController {
public constructor(
private readonly configurationService: ConfigurationService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly subscriptionService: SubscriptionService
) {}
@Get('stripe/callback')
public async stripeCallback(@Req() req, @Res() res) {
await this.subscriptionService.createSubscription(
req.query.checkoutSessionId
);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
}
@Post('stripe/checkout-session')
@UseGuards(AuthGuard('jwt'))
public async createCheckoutSession(
@Body() { couponId, priceId }: { couponId: string; priceId: string }
) {
try {
return await this.subscriptionService.createCheckoutSession({
couponId,
priceId,
userId: this.request.user.id
});
} catch (error) {
console.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

View File

@ -1,13 +0,0 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service';
@Module({
imports: [],
controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService]
})
export class SubscriptionModule {}

View File

@ -1,89 +0,0 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { addDays } from 'date-fns';
import Stripe from 'stripe';
@Injectable()
export class SubscriptionService {
private stripe: Stripe;
public constructor(
private readonly configurationService: ConfigurationService,
private prisma: PrismaService
) {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
{
apiVersion: '2020-08-27'
}
);
}
public async createCheckoutSession({
couponId,
priceId,
userId
}: {
couponId?: string;
priceId: string;
userId: string;
}) {
const checkoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = {
cancel_url: `${this.configurationService.get('ROOT_URL')}/account`,
client_reference_id: userId,
line_items: [
{
price: priceId,
quantity: 1
}
],
mode: 'payment',
payment_method_types: ['card'],
success_url: `${this.configurationService.get(
'ROOT_URL'
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
};
if (couponId) {
checkoutSessionCreateParams.discounts = [
{
coupon: couponId
}
];
}
const session = await this.stripe.checkout.sessions.create(
checkoutSessionCreateParams
);
return {
sessionId: session.id
};
}
public async createSubscription(aCheckoutSessionId: string) {
try {
const session = await this.stripe.checkout.sessions.retrieve(
aCheckoutSessionId
);
await this.prisma.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
User: {
connect: {
id: session.client_reference_id
}
}
}
});
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id
});
} catch (error) {
console.error(error);
}
}
}

View File

@ -1,7 +1,4 @@
import { DataSource } from '@prisma/client';
export interface LookupItem { export interface LookupItem {
dataSource: DataSource;
name: string; name: string;
symbol: string; symbol: string;
} }

View File

@ -1,7 +1,6 @@
import { Currency, DataSource } from '@prisma/client'; import { Currency } from '@prisma/client';
export interface SymbolItem { export interface SymbolItem {
currency: Currency; currency: Currency;
dataSource: DataSource;
marketPrice: number; marketPrice: number;
} }

View File

@ -1,4 +1,4 @@
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { import {
Controller, Controller,
Get, Get,
@ -28,12 +28,9 @@ export class SymbolController {
*/ */
@Get('lookup') @Get('lookup')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async lookupSymbol( public async lookupSymbol(@Query() { query }): Promise<LookupItem[]> {
@Query() { query = '' }
): Promise<{ items: LookupItem[] }> {
try { try {
const encodedQuery = encodeURIComponent(query.toLowerCase()); return this.symbolService.lookup(query);
return this.symbolService.lookup(encodedQuery);
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),

View File

@ -1,8 +1,8 @@
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 { 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, DataSource } from '@prisma/client'; import { Currency } 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';
@ -10,45 +10,55 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
@Injectable() @Injectable()
export class SymbolService { export class SymbolService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService
) {} ) {}
public async get(aSymbol: string): Promise<SymbolItem> { public async get(aSymbol: string): Promise<SymbolItem> {
const response = await this.dataProviderService.get([aSymbol]); const response = await this.dataProviderService.get([aSymbol]);
const { currency, dataSource, marketPrice } = response[aSymbol]; const { currency, marketPrice } = response[aSymbol];
return { return {
dataSource,
marketPrice, marketPrice,
currency: <Currency>(<unknown>currency) currency: <Currency>(<unknown>currency)
}; };
} }
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> { public async lookup(aQuery: string): Promise<LookupItem[]> {
const results: { items: LookupItem[] } = { items: [] }; const get = bent(
`https://query1.finance.yahoo.com/v1/finance/search?q=${aQuery}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
if (!aQuery) { 'GET',
return results; 'json',
} 200
);
try { try {
const { items } = await this.dataProviderService.search(aQuery); const { quotes } = await get();
results.items = items;
// Add custom symbols return quotes
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations(); .filter(({ isYahooFinance }) => {
scraperConfigurations.forEach((scraperConfiguration) => { return isYahooFinance;
if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) { })
results.items.push({ .filter(({ quoteType }) => {
dataSource: DataSource.GHOSTFOLIO, return (
name: scraperConfiguration.name, quoteType === 'CRYPTOCURRENCY' ||
symbol: scraperConfiguration.symbol quoteType === 'EQUITY' ||
}); quoteType === 'ETF'
} );
}); })
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD
return symbol.includes('USD');
}
return results; return true;
})
.map(({ longname, shortname, symbol }) => {
return {
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
});
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View File

@ -1,7 +0,0 @@
import { Currency, ViewMode } from '@prisma/client';
export interface UserSettingsParams {
currency?: Currency;
userId: string;
viewMode?: ViewMode;
}

View File

@ -0,0 +1,21 @@
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;
}

View File

@ -1,10 +1,7 @@
import { Currency, ViewMode } from '@prisma/client'; import { Currency } from '@prisma/client';
import { IsString } from 'class-validator'; import { IsString } from 'class-validator';
export class UpdateUserSettingsDto { export class UpdateUserSettingsDto {
@IsString() @IsString()
baseCurrency: Currency; currency: Currency;
@IsString()
viewMode: ViewMode;
} }

View File

@ -1,14 +1,8 @@
import { User } from '@ghostfolio/common/interfaces'; import { RequestWithUser } from '@ghostfolio/api/app/interfaces/request-with-user.type';
import { import { getPermissions, hasPermission, permissions } from '@ghostfolio/helper';
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpException, HttpException,
Inject, Inject,
@ -21,11 +15,10 @@ import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Provider } from '@prisma/client'; import { Provider } from '@prisma/client';
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 { UserSettingsParams } from './interfaces/user-settings-params.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';
@ -37,27 +30,6 @@ export class UserController {
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteUser(@Param('id') id: string): Promise<UserModel> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.deleteUser
) ||
id === this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.userService.deleteUser({
id
});
}
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getUser(@Param('id') id: string): Promise<User> { public async getUser(@Param('id') id: string): Promise<User> {
@ -93,20 +65,9 @@ export class UserController {
); );
} }
const userSettings: UserSettingsParams = { return await this.userService.updateUserSettings({
currency: data.baseCurrency, currency: data.currency,
userId: this.request.user.id userId: this.request.user.id
}; });
if (
hasPermission(
getPermissions(this.request.user.role),
permissions.updateViewMode
)
) {
userSettings.viewMode = data.viewMode;
}
return await this.userService.updateUserSettings(userSettings);
} }
} }

View File

@ -1,14 +1,17 @@
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 { locale } from '@ghostfolio/common/config'; import {
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces'; getPermissions,
import { getPermissions, permissions } from '@ghostfolio/common/permissions'; locale,
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; permissions,
resetHours
} from '@ghostfolio/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client'; import { Currency, Prisma, Provider, User } from '@prisma/client';
import { isBefore } from 'date-fns'; import { add } from 'date-fns';
import { UserSettingsParams } from './interfaces/user-settings-params.interface'; import { UserWithSettings } from '../interfaces/user-with-settings';
import { User as IUser } from './interfaces/user.interface';
const crypto = require('crypto'); const crypto = require('crypto');
@ -25,9 +28,8 @@ export class UserService {
Account, Account,
alias, alias,
id, id,
permissions, 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: {
@ -37,11 +39,15 @@ export class UserService {
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
}); });
const currentPermissions = getPermissions(role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
return { return {
alias, alias,
id, id,
permissions,
subscription,
access: access.map((accessItem) => { access: access.map((accessItem) => {
return { return {
alias: accessItem.User.alias, alias: accessItem.User.alias,
@ -49,10 +55,14 @@ export class UserService {
}; };
}), }),
accounts: Account, accounts: Account,
permissions: currentPermissions,
settings: { settings: {
locale, baseCurrency: Settings?.currency || UserService.DEFAULT_CURRENCY,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, locale
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT },
subscription: {
expiresAt: resetHours(add(new Date(), { days: 7 })),
type: 'Trial'
} }
}; };
} }
@ -60,64 +70,25 @@ export class UserService {
public async user( public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
const userFromDatabase = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
include: { Account: true, Settings: true, Subscription: true }, include: { Account: true, Settings: true },
where: userWhereUniqueInput where: userWhereUniqueInput
}); });
const user: UserWithSettings = userFromDatabase; if (user?.Settings) {
if (!user.Settings.currency) {
const currentPermissions = getPermissions(userFromDatabase.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
user.permissions = currentPermissions;
if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) {
// Set default currency if needed // Set default currency if needed
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY; user.Settings.currency = UserService.DEFAULT_CURRENCY;
} }
} else if (userFromDatabase) { } else if (user) {
// Set default settings if needed // Set default settings if needed
userFromDatabase.Settings = { user.Settings = {
currency: UserService.DEFAULT_CURRENCY, currency: UserService.DEFAULT_CURRENCY,
updatedAt: new Date(), updatedAt: new Date(),
userId: userFromDatabase?.id, userId: user?.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
};
}
if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => {
return permission !== permissions.updateViewMode;
});
user.Settings.viewMode = ViewMode.ZEN;
}
}
return user; return user;
} }
@ -192,28 +163,6 @@ export class UserService {
} }
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> { public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
await this.prisma.access.deleteMany({
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
});
await this.prisma.account.deleteMany({
where: { userId: where.id }
});
await this.prisma.analytics.delete({
where: { userId: where.id }
});
await this.prisma.order.deleteMany({
where: { userId: where.id }
});
try {
await this.prisma.settings.delete({
where: { userId: where.id }
});
} catch {}
return this.prisma.user.delete({ return this.prisma.user.delete({
where where
}); });
@ -221,9 +170,11 @@ export class UserService {
public async updateUserSettings({ public async updateUserSettings({
currency, currency,
userId, userId
viewMode }: {
}: UserSettingsParams) { currency: Currency;
userId: string;
}) {
await this.prisma.settings.upsert({ await this.prisma.settings.upsert({
create: { create: {
currency, currency,
@ -231,12 +182,10 @@ export class UserService {
connect: { connect: {
id: userId id: userId
} }
}, }
viewMode
}, },
update: { update: {
currency, currency
viewMode
}, },
where: { where: {
userId: userId userId: userId

View File

@ -1,4 +1,3 @@
export const environment = { export const environment = {
production: true, production: true
version: `v${require('../../../../package.json').version}`
}; };

View File

@ -1,4 +1,3 @@
export const environment = { export const environment = {
production: false, production: false
version: 'dev'
}; };

View File

@ -1,4 +1,7 @@
import { PortfolioItem, Position } from '@ghostfolio/common/interfaces'; import {
PortfolioItem,
Position
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
import { Order } from '../order'; import { Order } from '../order';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
import { EvaluationResult } from './evaluation-result.interface'; import { EvaluationResult } from './evaluation-result.interface';

View File

@ -1,42 +1,35 @@
import { Account, Currency, SymbolProfile } from '@prisma/client'; import { Currency, Platform } from '@prisma/client';
import { endOfToday, isAfter, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces'; import { IOrder } from '../services/interfaces/interfaces';
import { OrderType } from './order-type'; import { OrderType } from './order-type';
export class Order { export class Order {
private account: Account;
private currency: Currency; private currency: Currency;
private fee: number; private fee: number;
private date: string; private date: string;
private id: string; private id: string;
private quantity: number; private quantity: number;
private platform: Platform;
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;
public constructor(data: IOrder) { public constructor(data: IOrder) {
this.account = data.account;
this.currency = data.currency; this.currency = data.currency;
this.fee = data.fee; this.fee = data.fee;
this.date = data.date; this.date = data.date;
this.id = data.id || uuidv4(); this.id = data.id || uuidv4();
this.platform = data.platform;
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;
this.total = this.quantity * data.unitPrice; this.total = this.quantity * data.unitPrice;
} }
public getAccount() {
return this.account;
}
public getCurrency() { public getCurrency() {
return this.currency; return this.currency;
} }
@ -53,8 +46,8 @@ export class Order {
return this.id; return this.id;
} }
public getIsDraft() { public getPlatform() {
return isAfter(parseISO(this.date), endOfToday()); return this.platform;
} }
public getQuantity() { public getQuantity() {
@ -65,10 +58,6 @@ export class Order {
return this.symbol; return this.symbol;
} }
getSymbolProfile() {
return this.symbolProfile;
}
public getTotal() { public getTotal() {
return this.total; return this.total;
} }

View File

@ -1,142 +1,76 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; 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 { Currency, 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('../app/account/account.service', () => {
return {
AccountService: jest.fn().mockImplementation(() => {
return {
getCashDetails: () => Promise.resolve({ accounts: [], balance: 0 })
};
})
};
});
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 accountService: AccountService; 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 () => {
accountService = new AccountService(null, null, null); 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
); );
exchangeRateDataService = new ExchangeRateDataService(null); ghostfolioScraperApiService = app.get<GhostfolioScraperApiService>(
rulesService = new RulesService(); GhostfolioScraperApiService
);
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();
portfolio = new Portfolio( portfolio = new Portfolio(
accountService,
dataProviderService, dataProviderService,
exchangeRateDataService, exchangeRateDataService,
rulesService rulesService
); );
portfolio.setUser({ portfolio.setUser({
accessToken: null, accessToken: null,
Account: [
{
accountType: AccountType.SECURITIES,
balance: 0,
createdAt: new Date(),
currency: Currency.USD,
id: DEFAULT_ACCOUNT_ID,
isDefault: true,
name: 'Default Account',
platformId: null,
updatedAt: new Date(),
userId: USER_ID
}
],
alias: 'Test', alias: 'Test',
authChallenge: null,
createdAt: new Date(), createdAt: new Date(),
id: USER_ID, id: USER_ID,
provider: null, provider: null,
@ -144,8 +78,7 @@ 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()
@ -161,52 +94,12 @@ describe('Portfolio', () => {
it('should return empty details', async () => { it('should return empty details', async () => {
const details = await portfolio.getDetails('1d'); const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({ expect(details).toEqual({});
_GF_CASH: {
accounts: {},
allocationCurrent: NaN, // TODO
allocationInvestment: NaN, // TODO
countries: [],
currency: 'CHF',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: 0,
marketPrice: 0,
marketState: 'open',
name: 'Cash',
quantity: 0,
sectors: [],
symbol: '_GF_CASH',
transactionCount: 0,
type: 'Cash',
value: 0
}
});
}); });
it('should return empty details', async () => { it('should return empty details', async () => {
const details = await portfolio.getDetails('max'); const details = await portfolio.getDetails('max');
expect(details).toMatchObject({ expect(details).toEqual({});
_GF_CASH: {
accounts: {},
allocationCurrent: NaN, // TODO
allocationInvestment: NaN, // TODO
countries: [],
currency: 'CHF',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: 0,
marketPrice: 0,
marketState: 'open',
name: 'Cash',
quantity: 0,
sectors: [],
symbol: '_GF_CASH',
transactionCount: 0,
type: 'Cash',
value: 0
}
});
}); });
it('should return zero performance for 1d', async () => { it('should return zero performance for 1d', async () => {
@ -240,13 +133,12 @@ describe('Portfolio', () => {
accountUserId: USER_ID, accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0, fee: 0,
date: new Date(), date: new Date(),
id: '8d999347-dee2-46ee-88e1-26b344e71fcc', id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
platformId: null,
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,
@ -265,8 +157,20 @@ describe('Portfolio', () => {
const details = await portfolio.getDetails('1d'); const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({ expect(details).toMatchObject({
BTCUSD: { BTCUSD: {
accounts: { currency: Currency.USD,
[UNKNOWN_KEY]: { exchange: 'Other',
grossPerformance: 0,
grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
),
// marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
platforms: {
Other: {
/*current: exchangeRateDataService.toCurrency( /*current: exchangeRateDataService.toCurrency(
1 * 49631.24, 1 * 49631.24,
Currency.USD, Currency.USD,
@ -279,24 +183,10 @@ describe('Portfolio', () => {
) )
} }
}, },
allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
),
marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
quantity: 1, quantity: 1,
// shareCurrent: 0.9999999559148652,
shareInvestment: 1,
symbol: 'BTCUSD', symbol: 'BTCUSD',
transactionCount: 1,
type: 'Cryptocurrency' type: 'Cryptocurrency'
} }
}); });
@ -331,9 +221,7 @@ describe('Portfolio', () => {
expect(portfolio.getPositions(getYesterday())).toMatchObject({}); expect(portfolio.getPositions(getYesterday())).toMatchObject({});
expect(portfolio.getSymbols(getYesterday())).toEqual([]); expect(portfolio.getSymbols(getYesterday())).toEqual(['BTCUSD']);
expect(portfolio.getSymbols(new Date())).toEqual(['BTCUSD']);
}); });
}); });
@ -345,13 +233,12 @@ describe('Portfolio', () => {
accountUserId: USER_ID, accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0, fee: 0,
date: new Date(getUtc('2018-01-05')), date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
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,
@ -367,28 +254,11 @@ describe('Portfolio', () => {
) )
); );
/*const details = await portfolio.getDetails('1d'); const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({ expect(details).toMatchObject({
ETHUSD: { ETHUSD: {
accounts: {
[UNKNOWN_KEY]: {
current: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
original: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
)
}
},
// allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD, currency: Currency.USD,
exchange: UNKNOWN_KEY, exchange: 'Other',
// grossPerformance: 0, // grossPerformance: 0,
// grossPerformancePercent: 0, // grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency( investment: exchangeRateDataService.toCurrency(
@ -396,14 +266,29 @@ describe('Portfolio', () => {
Currency.USD, Currency.USD,
baseCurrency baseCurrency
), ),
marketPrice: 3915.337, // marketPrice: 57973.008,
name: 'Ethereum USD', name: 'Ethereum USD',
platforms: {
Other: {
/*current: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),*/
original: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
)
}
},
quantity: 0.2, quantity: 0.2,
transactionCount: 1, // shareCurrent: 1,
shareInvestment: 1,
symbol: 'ETHUSD', symbol: 'ETHUSD',
type: 'Cryptocurrency' type: 'Cryptocurrency'
} }
});*/ });
expect(portfolio.getFees()).toEqual(0); expect(portfolio.getFees()).toEqual(0);
@ -427,7 +312,7 @@ describe('Portfolio', () => {
baseCurrency baseCurrency
), ),
investmentInOriginalCurrency: 0.2 * 991.49, investmentInOriginalCurrency: 0.2 * 991.49,
// marketPrice: 3915.337, // marketPrice: 0,
quantity: 0.2 quantity: 0.2
} }
}); });
@ -442,13 +327,12 @@ describe('Portfolio', () => {
accountUserId: USER_ID, accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0, fee: 0,
date: new Date(getUtc('2018-01-05')), date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
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,
@ -459,13 +343,12 @@ describe('Portfolio', () => {
accountUserId: USER_ID, accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0, fee: 0,
date: new Date(getUtc('2018-01-28')), date: new Date(getUtc('2018-01-28')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
platformId: null,
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,
@ -505,7 +388,7 @@ describe('Portfolio', () => {
baseCurrency baseCurrency
), ),
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050, investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
// marketPrice: 3641.984, // marketPrice: 0,
quantity: 0.5 quantity: 0.5
} }
}); });
@ -520,13 +403,12 @@ describe('Portfolio', () => {
accountUserId: USER_ID, accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.EUR, currency: Currency.EUR,
dataSource: DataSource.YAHOO,
date: new Date(getUtc('2017-08-16')), date: new Date(getUtc('2017-08-16')),
fee: 2.99, fee: 2.99,
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475', id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
platformId: null,
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,
@ -537,13 +419,12 @@ describe('Portfolio', () => {
accountUserId: USER_ID, accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 2.99, fee: 2.99,
date: new Date(getUtc('2018-01-05')), date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
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,
@ -611,13 +492,12 @@ describe('Portfolio', () => {
accountUserId: USER_ID, accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0, fee: 1.0,
date: new Date(getUtc('2018-01-05')), date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
platformId: null,
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,
@ -628,13 +508,12 @@ describe('Portfolio', () => {
accountUserId: USER_ID, accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0, fee: 1.0,
date: new Date(getUtc('2018-01-28')), date: new Date(getUtc('2018-01-28')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
platformId: null,
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,
@ -645,13 +524,12 @@ describe('Portfolio', () => {
accountUserId: USER_ID, accountUserId: USER_ID,
createdAt: null, createdAt: null,
currency: Currency.USD, currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0, fee: 1.0,
date: new Date(getUtc('2018-01-31')), date: new Date(getUtc('2018-01-31')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc', id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
platformId: null,
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,
@ -659,7 +537,8 @@ describe('Portfolio', () => {
} }
]); ]);
expect(portfolio.getCommittedFunds()).toEqual( // TODO: Fix
/*expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency( exchangeRateDataService.toCurrency(
0.2 * 991.49, 0.2 * 991.49,
Currency.USD, Currency.USD,
@ -675,7 +554,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)
@ -687,11 +566,12 @@ 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',
investment: exchangeRateDataService.toCurrency( // TODO: Fix
/*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
@ -701,4 +581,8 @@ describe('Portfolio', () => {
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']); expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
}); });
}); });
afterAll(async () => {
prismaService.$disconnect();
});
}); });

View File

@ -1,20 +1,8 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
import { import {
PortfolioItem, PortfolioItem,
PortfolioPerformance, Position
PortfolioPosition, } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-item.interface';
PortfolioReport, import { getToday, getYesterday, resetHours } from '@ghostfolio/helper';
Position,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { Currency, Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list';
import { import {
add, add,
format, format,
@ -34,21 +22,26 @@ 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 { OrderWithPlatform } from '../app/order/interfaces/order-with-platform.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, MarketState, Type } 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 { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from './rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from './rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from './rules/currency-cluster-risk/base-currency-initial-investment'; import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from './rules/currency-cluster-risk/base-currency-initial-investment';
import { CurrencyClusterRiskCurrentInvestment } from './rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskCurrentInvestment } from './rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from './rules/currency-cluster-risk/initial-investment'; import { CurrencyClusterRiskInitialInvestment } from './rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from './rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from './rules/fees/fee-ratio-initial-investment';
import { PlatformClusterRiskCurrentInvestment } from './rules/platform-cluster-risk/current-investment';
import { PlatformClusterRiskInitialInvestment } from './rules/platform-cluster-risk/initial-investment';
import { PlatformClusterRiskSinglePlatform } from './rules/platform-cluster-risk/single-platform';
export class Portfolio implements PortfolioInterface { export class Portfolio implements PortfolioInterface {
private orders: Order[] = []; private orders: Order[] = [];
@ -56,7 +49,6 @@ export class Portfolio implements PortfolioInterface {
private user: UserWithSettings; private user: UserWithSettings;
public constructor( public constructor(
private accountService: AccountService,
private dataProviderService: DataProviderService, private dataProviderService: DataProviderService,
private exchangeRateDataService: ExchangeRateDataService, private exchangeRateDataService: ExchangeRateDataService,
private rulesService: RulesService private rulesService: RulesService
@ -65,7 +57,7 @@ export class Portfolio implements PortfolioInterface {
public async addCurrentPortfolioItems() { public async addCurrentPortfolioItems() {
const currentData = await this.dataProviderService.get(this.getSymbols()); const currentData = await this.dataProviderService.get(this.getSymbols());
const currentDate = new Date(); let currentDate = new Date();
const year = getYear(currentDate); const year = getYear(currentDate);
const month = getMonth(currentDate); const month = getMonth(currentDate);
@ -76,7 +68,7 @@ export class Portfolio implements PortfolioInterface {
const [portfolioItemsYesterday] = this.get(yesterday); const [portfolioItemsYesterday] = this.get(yesterday);
const positions: { [symbol: string]: Position } = {}; let positions: { [symbol: string]: Position } = {};
this.getSymbols().forEach((symbol) => { this.getSymbols().forEach((symbol) => {
positions[symbol] = { positions[symbol] = {
@ -90,9 +82,7 @@ export class Portfolio implements PortfolioInterface {
marketPrice: marketPrice:
currentData[symbol]?.marketPrice ?? currentData[symbol]?.marketPrice ??
portfolioItemsYesterday.positions[symbol]?.marketPrice, portfolioItemsYesterday.positions[symbol]?.marketPrice,
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity, quantity: portfolioItemsYesterday?.positions[symbol]?.quantity
transactionCount:
portfolioItemsYesterday?.positions[symbol]?.transactionCount
}; };
}); });
@ -108,45 +98,14 @@ export class Portfolio implements PortfolioInterface {
); );
// Set value after pushing today's portfolio items // Set value after pushing today's portfolio items
this.portfolioItems[portfolioItemsLength - 1].value = this.portfolioItems[portfolioItemsLength - 1].value = this.getValue(
this.getValue(today); today
);
} }
return this; return this;
} }
public async addFuturePortfolioItems() {
let investment = this.getInvestment(new Date());
this.getOrders()
.filter((order) => order.getIsDraft() === true)
.forEach((order) => {
investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
const portfolioItem = this.portfolioItems.find((item) => {
return item.date === order.getDate();
});
if (portfolioItem) {
portfolioItem.investment = investment;
} else {
this.portfolioItems.push({
investment,
date: order.getDate(),
grossPerformancePercent: 0,
positions: {},
value: 0
});
}
});
return this;
}
public createFromData({ public createFromData({
orders, orders,
portfolioItems, portfolioItems,
@ -158,27 +117,25 @@ export class Portfolio implements PortfolioInterface {
}): Portfolio { }): Portfolio {
orders.forEach( orders.forEach(
({ ({
account,
currency, currency,
fee, fee,
date, date,
id, id,
platform,
quantity, quantity,
symbol, symbol,
symbolProfile,
type, type,
unitPrice unitPrice
}) => { }) => {
this.orders.push( this.orders.push(
new Order({ new Order({
account,
currency, currency,
fee, fee,
date, date,
id, id,
platform,
quantity, quantity,
symbol, symbol,
symbolProfile,
type, type,
unitPrice unitPrice
}) })
@ -212,8 +169,6 @@ export class Portfolio implements PortfolioInterface {
if (filteredPortfolio) { if (filteredPortfolio) {
return [cloneDeep(filteredPortfolio)]; return [cloneDeep(filteredPortfolio)];
} }
return [];
} }
return cloneDeep(this.portfolioItems); return cloneDeep(this.portfolioItems);
@ -235,23 +190,17 @@ export class Portfolio implements PortfolioInterface {
const [portfolioItemsNow] = await this.get(new Date()); const [portfolioItemsNow] = await this.get(new Date());
const cashDetails = await this.accountService.getCashDetails( const investment = this.getInvestment(new Date());
this.user.id,
this.user.Settings.currency
);
const investment = this.getInvestment(new Date()) + cashDetails.balance;
const portfolioItems = this.get(new Date()); const portfolioItems = this.get(new Date());
const symbols = this.getSymbols(new Date()); const symbols = this.getSymbols(new Date());
const value = this.getValue() + cashDetails.balance; const value = this.getValue();
const details: { [symbol: string]: PortfolioPosition } = {}; const details: { [symbol: string]: PortfolioPosition } = {};
const data = await this.dataProviderService.get(symbols); const data = await this.dataProviderService.get(symbols);
symbols.forEach((symbol) => { symbols.forEach((symbol) => {
const accounts: PortfolioPosition['accounts'] = {}; const platforms: PortfolioPosition['platforms'] = {};
let countriesOfSymbol: Country[];
let sectorsOfSymbol: Sector[];
const [portfolioItem] = portfolioItems; const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => { const ordersBySymbol = this.getOrders().filter((order) => {
@ -276,51 +225,25 @@ export class Portfolio implements PortfolioInterface {
originalValueOfSymbol *= -1; originalValueOfSymbol *= -1;
} }
if ( if (platforms[orderOfSymbol.getPlatform()?.name || 'Other']?.current) {
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current platforms[
) { orderOfSymbol.getPlatform()?.name || 'Other'
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].current += ].current += currentValueOfSymbol;
currentValueOfSymbol; platforms[
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].original += orderOfSymbol.getPlatform()?.name || 'Other'
originalValueOfSymbol; ].original += originalValueOfSymbol;
} else { } else {
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = { platforms[orderOfSymbol.getPlatform()?.name || 'Other'] = {
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
};
});
sectorsOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.sectors as Prisma.JsonArray) ?? []
).map((sector) => {
const { name, weight } = sector as Prisma.JsonObject;
return {
name: (name as string) ?? UNKNOWN_KEY,
weight: weight as number
};
});
}); });
let now = portfolioItemsNow.positions[symbol].marketPrice; let now = portfolioItemsNow.positions[symbol].marketPrice;
// 1d // 1d
let before = portfolioItemsBefore?.positions[symbol].marketPrice; let before = portfolioItemsBefore.positions[symbol].marketPrice;
if (aDateRange === 'ytd') { if (aDateRange === 'ytd') {
before = before =
@ -337,7 +260,7 @@ export class Portfolio implements PortfolioInterface {
if ( if (
!isBefore( !isBefore(
parseISO(portfolioItemsNow.positions[symbol].firstBuyDate), parseISO(portfolioItemsNow.positions[symbol].firstBuyDate),
parseISO(portfolioItemsBefore?.date) parseISO(portfolioItemsBefore.date)
) )
) { ) {
// Trade was not before the date of portfolioItemsBefore, then override it with average price // Trade was not before the date of portfolioItemsBefore, then override it with average price
@ -351,17 +274,8 @@ export class Portfolio implements PortfolioInterface {
details[symbol] = { details[symbol] = {
...data[symbol], ...data[symbol],
accounts, platforms,
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
@ -369,22 +283,16 @@ 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,
sectors: sectorsOfSymbol, 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
}; };
}); });
details[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails,
investment,
value
});
return details; return details;
} }
@ -409,11 +317,7 @@ export class Portfolio implements PortfolioInterface {
} }
public getMinDate() { public getMinDate() {
const orders = this.getOrders().filter( if (this.orders.length > 0) {
(order) => order.getIsDraft() === false
);
if (orders.length > 0) {
return new Date(this.orders[0].getDate()); return new Date(this.orders[0].getDate());
} }
@ -488,19 +392,6 @@ export class Portfolio implements PortfolioInterface {
return { return {
rules: { rules: {
accountClusterRisk: await this.rulesService.evaluate(
this,
[
new AccountClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
],
{ baseCurrency: this.user.Settings.currency }
),
currencyClusterRisk: await this.rulesService.evaluate( currencyClusterRisk: await this.rulesService.evaluate(
this, this,
[ [
@ -519,6 +410,19 @@ export class Portfolio implements PortfolioInterface {
], ],
{ baseCurrency: this.user.Settings.currency } { baseCurrency: this.user.Settings.currency }
), ),
platformClusterRisk: await this.rulesService.evaluate(
this,
[
new PlatformClusterRiskSinglePlatform(this.exchangeRateDataService),
new PlatformClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new PlatformClusterRiskCurrentInvestment(
this.exchangeRateDataService
)
],
{ baseCurrency: this.user.Settings.currency }
),
fees: await this.rulesService.evaluate( fees: await this.rulesService.evaluate(
this, this,
[new FeeRatioInitialInvestment(this.exchangeRateDataService)], [new FeeRatioInitialInvestment(this.exchangeRateDataService)],
@ -540,11 +444,9 @@ export class Portfolio implements PortfolioInterface {
} }
} }
} else { } else {
symbols = this.orders symbols = this.orders.map((order) => {
.filter((order) => order.getIsDraft() === false) return order.getSymbol();
.map((order) => { });
return order.getSymbol();
});
} }
// unique values // unique values
@ -553,9 +455,7 @@ export class Portfolio implements PortfolioInterface {
public getTotalBuy() { public getTotalBuy() {
return this.orders return this.orders
.filter( .filter((order) => order.getType() === 'BUY')
(order) => order.getIsDraft() === false && order.getType() === 'BUY'
)
.map((order) => { .map((order) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
order.getTotal(), order.getTotal(),
@ -568,9 +468,7 @@ export class Portfolio implements PortfolioInterface {
public getTotalSell() { public getTotalSell() {
return this.orders return this.orders
.filter( .filter((order) => order.getType() === 'SELL')
(order) => order.getIsDraft() === false && order.getType() === 'SELL'
)
.map((order) => { .map((order) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
order.getTotal(), order.getTotal(),
@ -581,13 +479,7 @@ export class Portfolio implements PortfolioInterface {
.reduce((previous, current) => previous + current, 0); .reduce((previous, current) => previous + current, 0);
} }
public getOrders(aSymbol?: string) { public getOrders() {
if (aSymbol) {
return this.orders.filter((order) => {
return order.getSymbol() === aSymbol;
});
}
return this.orders; return this.orders;
} }
@ -626,21 +518,20 @@ export class Portfolio implements PortfolioInterface {
return isFinite(value) ? value : null; return isFinite(value) ? value : null;
} }
public async setOrders(aOrders: OrderWithAccount[]) { public async setOrders(aOrders: OrderWithPlatform[]) {
this.orders = []; this.orders = [];
// Map data // Map data
aOrders.forEach((order) => { aOrders.forEach((order) => {
this.orders.push( this.orders.push(
new Order({ new Order({
account: order.Account, currency: <any>order.currency,
currency: order.currency,
date: order.date.toISOString(), date: order.date.toISOString(),
fee: order.fee, fee: order.fee,
platform: order.Platform,
quantity: order.quantity, quantity: order.quantity,
symbol: order.symbol, symbol: order.symbol,
symbolProfile: order.SymbolProfile, type: <any>order.type,
type: <OrderType>order.type,
unitPrice: order.unitPrice unitPrice: order.unitPrice
}) })
); );
@ -657,46 +548,6 @@ export class Portfolio implements PortfolioInterface {
return this; return this;
} }
private async getCashPosition({
cashDetails,
investment,
value
}: {
cashDetails: CashDetails;
investment: number;
value: number;
}) {
const accounts = {};
const cashValue = cashDetails.balance;
cashDetails.accounts.forEach((account) => {
accounts[account.name] = {
current: account.balance,
original: account.balance
};
});
return {
accounts,
allocationCurrent: cashValue / value,
allocationInvestment: cashValue / investment,
countries: [],
currency: Currency.CHF,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: cashValue,
marketPrice: 0,
marketState: MarketState.open,
name: Type.Cash,
quantity: 0,
sectors: [],
symbol: ghostfolioCashSymbol,
type: Type.Cash,
transactionCount: 0,
value: cashValue
};
}
/** /**
* TODO: Refactor * TODO: Refactor
*/ */
@ -731,8 +582,7 @@ export class Portfolio implements PortfolioInterface {
marketPrice: marketPrice:
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')] historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
?.marketPrice || 0, ?.marketPrice || 0,
quantity: 0, quantity: 0
transactionCount: 0
}; };
}); });
@ -773,17 +623,16 @@ export class Portfolio implements PortfolioInterface {
marketPrice: marketPrice:
historicalData[symbol]?.[format(yesterday, 'yyyy-MM-dd')] historicalData[symbol]?.[format(yesterday, 'yyyy-MM-dd')]
?.marketPrice || 0, ?.marketPrice || 0,
quantity: 0, quantity: 0
transactionCount: 0
}; };
}); });
this.portfolioItems.push( this.portfolioItems.push(
cloneDeep({ cloneDeep({
positions,
date: yesterday.toISOString(), date: yesterday.toISOString(),
grossPerformancePercent: 0, grossPerformancePercent: 0,
investment: 0, investment: 0,
positions: positions,
value: 0 value: 0
}) })
); );
@ -840,6 +689,8 @@ export class Portfolio implements PortfolioInterface {
} }
private updatePortfolioItems() { private updatePortfolioItems() {
// console.time('update-portfolio-items');
let currentDate = new Date(); let currentDate = new Date();
const year = getYear(currentDate); const year = getYear(currentDate);
@ -863,99 +714,103 @@ export class Portfolio implements PortfolioInterface {
} }
this.orders.forEach((order) => { this.orders.forEach((order) => {
if (order.getIsDraft() === false) { let index = this.portfolioItems.findIndex((item) => {
let index = this.portfolioItems.findIndex((item) => { const dateOfOrder = setDate(parseISO(order.getDate()), 1);
const dateOfOrder = setDate(parseISO(order.getDate()), 1); return isSameDay(parseISO(item.date), dateOfOrder);
return isSameDay(parseISO(item.date), dateOfOrder); });
});
if (index === -1) { if (index === -1) {
// if not found, we only have one order, which means we do not loop below // if not found, we only have one order, which means we do not loop below
index = 0; index = 0;
} }
for (let i = index; i < this.portfolioItems.length; i++) { for (let i = index; i < this.portfolioItems.length; i++) {
// Set currency // Set currency
this.portfolioItems[i].positions[order.getSymbol()].currency = this.portfolioItems[i].positions[
order.getCurrency(); order.getSymbol()
].currency = order.getCurrency();
if (order.getType() === 'BUY') {
if (
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate
) {
this.portfolioItems[i].positions[
order.getSymbol()
].firstBuyDate = resetHours(
parseISO(order.getDate())
).toISOString();
}
this.portfolioItems[i].positions[ this.portfolioItems[i].positions[
order.getSymbol() order.getSymbol()
].transactionCount += 1; ].quantity += order.getQuantity();
this.portfolioItems[i].positions[
order.getSymbol()
].investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency += order.getTotal();
if (order.getType() === 'BUY') { this.portfolioItems[
if ( i
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate ].investment += this.exchangeRateDataService.toCurrency(
) { order.getTotal(),
this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate = order.getCurrency(),
resetHours(parseISO(order.getDate())).toISOString(); this.user.Settings.currency
} );
} else if (order.getType() === 'SELL') {
this.portfolioItems[i].positions[
order.getSymbol()
].quantity -= order.getQuantity();
this.portfolioItems[i].positions[order.getSymbol()].quantity += if (
order.getQuantity(); this.portfolioItems[i].positions[order.getSymbol()].quantity === 0
this.portfolioItems[i].positions[order.getSymbol()].investment += ) {
this.exchangeRateDataService.toCurrency( this.portfolioItems[i].positions[order.getSymbol()].investment = 0;
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[ this.portfolioItems[i].positions[
order.getSymbol() order.getSymbol()
].investmentInOriginalCurrency += order.getTotal(); ].investmentInOriginalCurrency = 0;
} else {
this.portfolioItems[i].investment += this.portfolioItems[i].positions[
this.exchangeRateDataService.toCurrency( order.getSymbol()
order.getTotal(), ].investment -= this.exchangeRateDataService.toCurrency(
order.getCurrency(), order.getTotal(),
this.user.Settings.currency order.getCurrency(),
); this.user.Settings.currency
} else if (order.getType() === 'SELL') { );
this.portfolioItems[i].positions[order.getSymbol()].quantity -= this.portfolioItems[i].positions[
order.getQuantity(); order.getSymbol()
].investmentInOriginalCurrency -= order.getTotal();
if (
this.portfolioItems[i].positions[order.getSymbol()].quantity === 0
) {
this.portfolioItems[i].positions[
order.getSymbol()
].investment = 0;
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency = 0;
} else {
this.portfolioItems[i].positions[order.getSymbol()].investment -=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency -= order.getTotal();
}
this.portfolioItems[i].investment -=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
} }
this.portfolioItems[i].positions[order.getSymbol()].averagePrice = this.portfolioItems[
this.portfolioItems[i].positions[order.getSymbol()] i
.investmentInOriginalCurrency / ].investment -= this.exchangeRateDataService.toCurrency(
this.portfolioItems[i].positions[order.getSymbol()].quantity; order.getTotal(),
order.getCurrency(),
const currentValue = this.getValue( this.user.Settings.currency
parseISO(this.portfolioItems[i].date)
); );
this.portfolioItems[i].grossPerformancePercent =
currentValue / this.portfolioItems[i].investment - 1 || 0;
this.portfolioItems[i].value = currentValue;
} }
this.portfolioItems[i].positions[order.getSymbol()].averagePrice =
this.portfolioItems[i].positions[order.getSymbol()]
.investmentInOriginalCurrency /
this.portfolioItems[i].positions[order.getSymbol()].quantity;
const currentValue = this.getValue(
parseISO(this.portfolioItems[i].date)
);
this.portfolioItems[i].grossPerformancePercent =
currentValue / this.portfolioItems[i].investment - 1 || 0;
this.portfolioItems[i].value = currentValue;
} }
}); });
// console.timeEnd('update-portfolio-items');
} }
} }

View File

@ -1,7 +1,7 @@
import { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/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';

View File

@ -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';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
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';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
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';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
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';

View File

@ -1,4 +1,4 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
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';

View File

@ -1,9 +1,9 @@
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';
export class AccountClusterRiskCurrentInvestment extends Rule { export class PlatformClusterRiskCurrentInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(public exchangeRateDataService: ExchangeRateDataService) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Current Investment' name: 'Current Investment'
@ -18,22 +18,24 @@ export class AccountClusterRiskCurrentInvestment extends Rule {
} }
) { ) {
const ruleSettings = const ruleSettings =
aRuleSettingsMap[AccountClusterRiskCurrentInvestment.name]; aRuleSettingsMap[PlatformClusterRiskCurrentInvestment.name];
const accounts: { const platforms: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { [symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number; investment: number;
}; };
} = {}; } = {};
Object.values(aPositions).forEach((position) => { Object.values(aPositions).forEach((position) => {
for (const [account, { current }] of Object.entries(position.accounts)) { for (const [platform, { current }] of Object.entries(
if (accounts[account]?.investment) { position.platforms
accounts[account].investment += current; )) {
if (platforms[platform]?.investment) {
platforms[platform].investment += current;
} else { } else {
accounts[account] = { platforms[platform] = {
investment: current, investment: current,
name: account name: platform
}; };
} }
} }
@ -42,17 +44,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule {
let maxItem; let maxItem;
let totalInvestment = 0; let totalInvestment = 0;
Object.values(accounts).forEach((account) => { Object.values(platforms).forEach((platform) => {
if (!maxItem) { if (!maxItem) {
maxItem = account; maxItem = platform;
} }
// Calculate total investment // Calculate total investment
totalInvestment += account.investment; totalInvestment += platform.investment;
// Find maximum // Find maximum
if (account.investment > maxItem?.investment) { if (platform.investment > maxItem?.investment) {
maxItem = account; maxItem = platform;
} }
}); });

View File

@ -1,9 +1,9 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
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';
export class AccountClusterRiskInitialInvestment extends Rule { export class PlatformClusterRiskInitialInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(public exchangeRateDataService: ExchangeRateDataService) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Initial Investment' name: 'Initial Investment'
@ -18,7 +18,7 @@ export class AccountClusterRiskInitialInvestment extends Rule {
} }
) { ) {
const ruleSettings = const ruleSettings =
aRuleSettingsMap[AccountClusterRiskInitialInvestment.name]; aRuleSettingsMap[PlatformClusterRiskInitialInvestment.name];
const platforms: { const platforms: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { [symbol: string]: Pick<PortfolioPosition, 'name'> & {
@ -27,13 +27,15 @@ export class AccountClusterRiskInitialInvestment extends Rule {
} = {}; } = {};
Object.values(aPositions).forEach((position) => { Object.values(aPositions).forEach((position) => {
for (const [account, { original }] of Object.entries(position.accounts)) { for (const [platform, { original }] of Object.entries(
if (platforms[account]?.investment) { position.platforms
platforms[account].investment += original; )) {
if (platforms[platform]?.investment) {
platforms[platform].investment += original;
} else { } else {
platforms[account] = { platforms[platform] = {
investment: original, investment: original,
name: account name: platform
}; };
} }
} }

View File

@ -1,35 +1,35 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import { PortfolioPosition } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position.interface';
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';
export class AccountClusterRiskSingleAccount extends Rule { export class PlatformClusterRiskSinglePlatform extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) { public constructor(public exchangeRateDataService: ExchangeRateDataService) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Single Account' name: 'Single Platform'
}); });
} }
public evaluate(positions: { [symbol: string]: PortfolioPosition }) { public evaluate(positions: { [symbol: string]: PortfolioPosition }) {
const accounts: string[] = []; const platforms: string[] = [];
Object.values(positions).forEach((position) => { Object.values(positions).forEach((position) => {
for (const [account] of Object.entries(position.accounts)) { for (const [platform] of Object.entries(position.platforms)) {
if (!accounts.includes(account)) { if (!platforms.includes(platform)) {
accounts.push(account); platforms.push(platform);
} }
} }
}); });
if (accounts.length === 1) { if (platforms.length === 1) {
return { return {
evaluation: `All your investment is managed by a single account`, evaluation: `All your investment is managed by a single platform`,
value: false value: false
}; };
} }
return { return {
evaluation: `Your investment is managed by ${accounts.length} accounts`, evaluation: `Your investment is managed by ${platforms.length} platforms`,
value: true value: true
}; };
} }

View File

@ -1,8 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { bool, cleanEnv, num, port, str } from 'envalid';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { environment } from '../environments/environment';
import { Environment } from './interfaces/environment.interface'; import { Environment } from './interfaces/environment.interface';
@Injectable() @Injectable()
@ -14,12 +12,9 @@ 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_IMPORT: bool({ default: !environment.production }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }), GOOGLE_SECRET: str({ default: 'dummySecret' }),
@ -29,10 +24,7 @@ export class ConfigurationService {
RAKUTEN_RAPID_API_KEY: str({ default: '' }), RAKUTEN_RAPID_API_KEY: str({ default: '' }),
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }), ROOT_URL: str({ default: 'http://localhost:4200' })
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' })
}); });
} }

View File

@ -1,14 +1,13 @@
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
import { import {
benchmarks,
currencyPairs,
getUtc, getUtc,
isGhostfolioScraperApiSymbol, isGhostfolioScraperApiSymbol,
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { import {
differenceInHours, differenceInHours,
endOfToday,
format, format,
getDate, getDate,
getMonth, getMonth,
@ -19,8 +18,6 @@ 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 { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
@ -28,7 +25,6 @@ export class DataGatheringService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
private prisma: PrismaService private prisma: PrismaService
) {} ) {}
@ -118,13 +114,15 @@ export class DataGatheringService {
} }
} }
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(
aSymbolsWithStartDate: { date: Date; symbol: string }[]
) {
let hasError = false; let hasError = false;
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) { for (const { date, symbol } of aSymbolsWithStartDate) {
try { try {
const historicalData = await this.dataProviderService.getHistoricalRaw( const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }], [symbol],
date, date,
new Date() new Date()
); );
@ -185,25 +183,9 @@ export class DataGatheringService {
} }
} }
public async getCustomSymbolsToGather( private getBenchmarksToGather(startDate: Date) {
startDate?: Date const benchmarksToGather = benchmarks.map((symbol) => {
): Promise<IDataGatheringItem[]> {
const scraperConfigurations =
await this.ghostfolioScraperApi.getScraperConfigurations();
return scraperConfigurations.map((scraperConfiguration) => {
return { return {
dataSource: DataSource.GHOSTFOLIO,
date: startDate,
symbol: scraperConfiguration.symbol
};
});
}
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol, symbol,
date: startDate date: startDate
}; };
@ -211,7 +193,6 @@ 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'
}); });
@ -220,21 +201,42 @@ export class DataGatheringService {
return benchmarksToGather; return benchmarksToGather;
} }
private async getSymbols7D(): Promise<IDataGatheringItem[]> { private async getCustomSymbolsToGather(startDate: Date) {
const customSymbolsToGather = [];
if (this.configurationService.get('ENABLE_FEATURE_CUSTOM_SYMBOLS')) {
try {
const {
value: scraperConfigString
} = await this.prisma.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
JSON.parse(scraperConfigString).forEach((item) => {
customSymbolsToGather.push({
date: startDate,
symbol: item.symbol
});
});
} catch {}
}
return customSymbolsToGather;
}
private async getSymbols7D(): Promise<{ date: Date; symbol: string }[]> {
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: { dataSource: true, symbol: true }, select: { symbol: true }
where: {
date: {
lt: endOfToday() // no draft
}
}
}); });
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders const distinctOrdersWithDate = distinctOrders
.filter((distinctOrder) => { .filter((distinctOrder) => {
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol); return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
}) })
@ -245,15 +247,12 @@ export class DataGatheringService {
}; };
}); });
const currencyPairsToGather = currencyPairs.map( const currencyPairsToGather = currencyPairs.map((symbol) => {
({ dataSource, symbol }) => { return {
return { symbol,
dataSource, date: startDate
symbol, };
date: startDate });
};
}
);
const customSymbolsToGather = await this.getCustomSymbolsToGather( const customSymbolsToGather = await this.getCustomSymbolsToGather(
startDate startDate
@ -267,32 +266,24 @@ export class DataGatheringService {
]; ];
} }
private async getSymbolsMax(): Promise<IDataGatheringItem[]> { private async getSymbolsMax() {
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( const currencyPairsToGather = currencyPairs.map((symbol) => {
({ dataSource, symbol }) => { return {
return { symbol,
dataSource, date: startDate
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: { dataSource: true, date: true, symbol: true }, select: { date: true, symbol: true }
where: {
date: {
lt: endOfToday() // no draft
}
}
}); });
return [ return [

View File

@ -1,10 +1,10 @@
import { import {
isCrypto,
isGhostfolioScraperApiSymbol, isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper'; } from '@ghostfolio/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { 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,15 +12,16 @@ 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 { export class DataProviderService implements DataProviderInterface {
public constructor( public constructor(
private readonly alphaVantageService: AlphaVantageService, private readonly alphaVantageService: AlphaVantageService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
@ -119,57 +120,68 @@ export class DataProviderService {
} }
public async getHistoricalRaw( public async getHistoricalRaw(
aDataGatheringItems: IDataGatheringItem[], aSymbols: string[],
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
const result: { const filteredSymbols = aSymbols.filter((symbol) => {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; return !isGhostfolioScraperApiSymbol(symbol);
} = {}; });
const promises: Promise<{ const dataOfYahoo = await this.yahooFinanceService.getHistorical(
data: { [date: string]: IDataProviderHistoricalResponse }; filteredSymbols,
symbol: string; undefined,
}>[] = []; from,
for (const { dataSource, symbol } of aDataGatheringItems) { to
const dataProvider = this.getDataProvider(dataSource); );
if (dataProvider.canHandle(symbol)) {
promises.push( if (aSymbols.length === 1) {
dataProvider const symbol = aSymbols[0];
.getHistorical([symbol], undefined, from, to)
.then((data) => ({ data: data?.[symbol], symbol })) if (
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;
} }
} }
const allData = await Promise.all(promises); return dataOfYahoo;
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.');
}
} }
} }

View File

@ -1,11 +1,9 @@
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
@ -24,10 +22,6 @@ 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 }> {
@ -83,17 +77,7 @@ export class AlphaVantageService implements DataProviderInterface {
} }
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public search(aSymbol: string) {
const result = await this.alphaVantage.data.search(aSymbol); return this.alphaVantage.data.search(aSymbol);
return {
items: result?.bestMatches?.map((bestMatch) => {
return {
dataSource: DataSource.ALPHA_VANTAGE,
name: bestMatch['2. name'],
symbol: bestMatch['1. symbol']
};
})
};
} }
} }

View File

@ -1,22 +1,17 @@
import { import { getYesterday } from '@ghostfolio/helper';
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 * as bent from 'bent'; import * as bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { 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,
MarketState MarketState
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { PrismaService } from '../../prisma.service'; import { PrismaService } from '../../prisma.service';
import { ScraperConfig } from './interfaces/scraper-config.interface';
@Injectable() @Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface { export class GhostfolioScraperApiService implements DataProviderInterface {
@ -24,10 +19,6 @@ 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 }> {
@ -38,7 +29,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
try { try {
const symbol = aSymbols[0]; const symbol = aSymbols[0];
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol); const scraperConfig = await this.getScraperConfig(symbol);
const { marketPrice } = await this.prisma.marketData.findFirst({ const { marketPrice } = await this.prisma.marketData.findFirst({
orderBy: { orderBy: {
@ -53,7 +44,6 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
[symbol]: { [symbol]: {
marketPrice, marketPrice,
currency: scraperConfig?.currency, currency: scraperConfig?.currency,
dataSource: DataSource.GHOSTFOLIO,
marketState: MarketState.delayed, marketState: MarketState.delayed,
name: scraperConfig?.name name: scraperConfig?.name
} }
@ -80,17 +70,15 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
try { try {
const symbol = aSymbols[0]; const symbol = aSymbols[0];
const scraperConfiguration = await this.getScraperConfigurationBySymbol( const scraperConfig = await this.getScraperConfig(symbol);
symbol
);
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {}); const get = bent(scraperConfig?.url, 'GET', 'string', 200, {});
const html = await get(); const html = await get();
const $ = cheerio.load(html); const $ = cheerio.load(html);
const value = this.extractNumberFromString( const value = this.extractNumberFromString(
$(scraperConfiguration?.selector).text() $(scraperConfig?.selector).text()
); );
return { return {
@ -107,27 +95,6 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return {}; return {};
} }
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
try {
const {
value: scraperConfigString
} = await this.prisma.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
return JSON.parse(scraperConfigString);
} catch {}
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(
@ -139,10 +106,22 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
} }
} }
private async getScraperConfigurationBySymbol(aSymbol: string) { private async getScraperConfig(aSymbol: string) {
const scraperConfigurations = await this.getScraperConfigurations(); try {
return scraperConfigurations.find((scraperConfiguration) => { const {
return scraperConfiguration.symbol === aSymbol; value: scraperConfigString
}); } = await this.prisma.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
return JSON.parse(scraperConfigString).find((item) => {
return item.symbol === aSymbol;
});
} catch {}
return {};
} }
} }

View File

@ -1,9 +0,0 @@
import { Currency } from '@prisma/client';
export interface ScraperConfig {
currency: Currency;
name: string;
selector: string;
symbol: string;
url: string;
}

View File

@ -1,16 +1,11 @@
import { import { getToday, getYesterday } from '@ghostfolio/helper';
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 * as bent from 'bent'; import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns'; 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,
@ -28,13 +23,6 @@ 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 }> {
@ -51,7 +39,6 @@ export class RakutenRapidApiService implements DataProviderInterface {
return { return {
'GF.FEAR_AND_GREED_INDEX': { 'GF.FEAR_AND_GREED_INDEX': {
currency: undefined, currency: undefined,
dataSource: DataSource.RAKUTEN,
marketPrice: fgi.now.value, marketPrice: fgi.now.value,
marketState: MarketState.open, marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
@ -128,14 +115,6 @@ 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 };
@ -166,4 +145,8 @@ export class RakutenRapidApiService implements DataProviderInterface {
return undefined; return undefined;
} }
} }
public setPrisma(aPrismaService: PrismaService) {
this.prisma = aPrismaService;
}
} }

View File

@ -0,0 +1,24 @@
/*
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('{}');
});
});
});
*/

View File

@ -1,18 +1,16 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/helper';
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 * 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,
Industry,
MarketState, MarketState,
Sector,
Type Type
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { import {
@ -22,14 +20,8 @@ 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 }> {
@ -57,7 +49,6 @@ export class YahooFinanceService implements DataProviderInterface {
response[symbol] = { response[symbol] = {
currency: parseCurrency(value.price?.currency), currency: parseCurrency(value.price?.currency),
dataSource: DataSource.YAHOO,
exchange: this.parseExchange(value.price?.exchangeName), exchange: this.parseExchange(value.price?.exchangeName),
marketState: marketState:
value.price?.marketState === 'REGULAR' || isCrypto(symbol) value.price?.marketState === 'REGULAR' || isCrypto(symbol)
@ -68,6 +59,16 @@ export class YahooFinanceService implements DataProviderInterface {
type: this.parseType(this.getType(symbol, value)) type: this.parseType(this.getType(symbol, value))
}; };
const industry = this.parseIndustry(value.summaryProfile?.industry);
if (industry) {
response[symbol].industry = industry;
}
const sector = this.parseSector(value.summaryProfile?.sector);
if (sector) {
response[symbol].sector = sector;
}
const url = value.summaryProfile?.website; const url = value.summaryProfile?.website;
if (url) { if (url) {
response[symbol].url = url; response[symbol].url = url;
@ -132,49 +133,6 @@ 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&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=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
* *
@ -210,12 +168,61 @@ export class YahooFinanceService implements DataProviderInterface {
private parseExchange(aString: string): string { private parseExchange(aString: string): string {
if (aString?.toLowerCase() === 'ccc') { if (aString?.toLowerCase() === 'ccc') {
return UNKNOWN_KEY; return 'Other';
} }
return aString; return aString;
} }
private parseIndustry(aString: string): Industry {
if (aString === undefined) {
return undefined;
}
if (aString?.toLowerCase() === 'auto manufacturers') {
return Industry.Automotive;
} else if (aString?.toLowerCase() === 'biotechnology') {
return Industry.Biotechnology;
} else if (
aString?.toLowerCase() === 'drug manufacturers—specialty & generic'
) {
return Industry.Pharmaceutical;
} else if (
aString?.toLowerCase() === 'internet content & information' ||
aString?.toLowerCase() === 'internet retail'
) {
return Industry.Internet;
} else if (aString?.toLowerCase() === 'packaged foods') {
return Industry.Food;
} else if (aString?.toLowerCase() === 'software—application') {
return Industry.Software;
}
return Industry.Other;
}
private parseSector(aString: string): Sector {
if (aString === undefined) {
return undefined;
}
if (
aString?.toLowerCase() === 'consumer cyclical' ||
aString?.toLowerCase() === 'consumer defensive'
) {
return Sector.Consumer;
} else if (aString?.toLowerCase() === 'healthcare') {
return Sector.Healthcare;
} else if (
aString?.toLowerCase() === 'communication services' ||
aString?.toLowerCase() === 'technology'
) {
return Sector.Technology;
}
return Sector.Other;
}
private parseType(aString: string): Type { private parseType(aString: string): Type {
if (aString?.toLowerCase() === 'cryptocurrency') { if (aString?.toLowerCase() === 'cryptocurrency') {
return Type.Cryptocurrency; return Type.Cryptocurrency;
@ -225,11 +232,11 @@ export class YahooFinanceService implements DataProviderInterface {
return Type.Stock; return Type.Stock;
} }
return Type.Unknown; return Type.Other;
} }
} }
export const convertFromYahooSymbol = (aSymbol: string) => { export const convertFromYahooSymbol = (aSymbol: string) => {
const symbol = aSymbol.replace('-', ''); let symbol = aSymbol.replace('-', '');
return symbol.replace('=X', ''); return symbol.replace('=X', '');
}; };

View File

@ -1,4 +1,4 @@
import { getYesterday } from '@ghostfolio/common/helper'; import { getYesterday } from '@ghostfolio/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';

View File

@ -1,14 +1,10 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { Granularity } from './granularity.type';
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(
@ -19,6 +15,4 @@ export interface DataProviderInterface {
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>; }>;
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
} }

View File

@ -4,12 +4,9 @@ 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_IMPORT: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean; ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean;
GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string; GOOGLE_SECRET: string;
@ -20,7 +17,4 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_HOST: string; REDIS_HOST: string;
REDIS_PORT: number; REDIS_PORT: number;
ROOT_URL: string; ROOT_URL: string;
STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string;
WEB_AUTH_RP_ID: string;
} }

Some files were not shown because too many files have changed in this diff Show More