Compare commits
39 Commits
Author | SHA1 | Date | |
---|---|---|---|
0873f539c5 | |||
6dcd801d05 | |||
77065dac50 | |||
438484879d | |||
e37a650c70 | |||
6e8c90b3fc | |||
9e1a7fc981 | |||
ff638adf03 | |||
fa44cee781 | |||
db1d474ddf | |||
994275e093 | |||
ee397c8047 | |||
7203939c42 | |||
9725f16c81 | |||
bb8b1e4f43 | |||
9d3610331a | |||
0043b44670 | |||
bbc4e64cb4 | |||
c7f4825499 | |||
8f583709ef | |||
4c30212a72 | |||
cade2f6a5e | |||
3b9a8fabb5 | |||
3435b3a348 | |||
5d39b267ab | |||
ffaaa14dba | |||
c65746d119 | |||
1a6840f1f6 | |||
fb7fb886f6 | |||
127abb8f4e | |||
ed1136999a | |||
9f545e3e2b | |||
1602f976f0 | |||
4bf4c1a8a3 | |||
e78755c280 | |||
7772684413 | |||
955302666e | |||
ddce8cc7f9 | |||
aca0d77e91 |
88
CHANGELOG.md
88
CHANGELOG.md
@ -5,6 +5,94 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## 1.98.0 - 29.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the date range component to the holdings tab
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the statistics section on the about page (users in Slack community)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the creation of historical data in the admin control panel (upsert instead of update)
|
||||
- Fixed the scrolling issue in the position detail dialog on mobile
|
||||
|
||||
## 1.97.0 - 28.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the transactions to the position detail dialog
|
||||
- Added support for dividend
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.96.0 - 27.12.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Made the data provider warning more discreet
|
||||
- Upgraded `http-status-codes` from version `2.1.4` to `2.2.0`
|
||||
- Upgraded `ngx-device-detector` from version `2.1.1` to `3.0.0`
|
||||
- Upgraded `ngx-markdown` from version `12.0.1` to `13.0.0`
|
||||
- Upgraded `ngx-stripe` from version `12.0.2` to `13.0.0`
|
||||
- Upgraded `prisma` from version `3.6.0` to `3.7.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the file type detection in the import functionality for transactions
|
||||
|
||||
## 1.95.0 - 26.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a warning to the log if the data gathering fails
|
||||
|
||||
### Fixed
|
||||
|
||||
- Filtered potential `null` currencies
|
||||
- Improved the 7d data gathering optimization for currencies
|
||||
|
||||
## 1.94.0 - 25.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for cryptocurrencies _Cosmos_ (`ATOM-USD`) and _Polkadot_ (`DOT-USD`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 30 days
|
||||
- Made the import functionality for transactions by `csv` files more flexible
|
||||
- Optimized the 7d data gathering (only consider symbols with incomplete market data)
|
||||
- Upgraded `prettier` from version `2.3.2` to `2.5.1`
|
||||
|
||||
## 1.93.0 - 21.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for cryptocurrency _Solana_ (`SOL-USD`)
|
||||
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Converted errors to warnings in portfolio calculator
|
||||
|
||||
## 1.92.0 - 19.12.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a line chart to the historical data view in the admin control panel
|
||||
- Supported the update of historical data in the admin control panel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the redirection on logout
|
||||
- Fixed the permission for the system status page
|
||||
|
||||
## 1.91.0 - 18.12.2021
|
||||
|
||||
### Changed
|
||||
|
42
README.md
42
README.md
@ -34,7 +34,7 @@
|
||||
|
||||
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
|
||||
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find further instructions in the section [Run with Docker](#run-with-docker).
|
||||
If you prefer to run Ghostfolio on your own infrastructure (self-hosting), please find further instructions in the section [Run with Docker](#run-with-docker-self-hosting).
|
||||
|
||||
## Why Ghostfolio?
|
||||
|
||||
@ -81,27 +81,43 @@ The backend is based on [NestJS](https://nestjs.com) using [PostgreSQL](https://
|
||||
|
||||
The frontend is built with [Angular](https://angular.io) and uses [Angular Material](https://material.angular.io) with utility classes from [Bootstrap](https://getbootstrap.com).
|
||||
|
||||
## Run with Docker
|
||||
## Run with Docker (self-hosting)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Docker](https://www.docker.com/products/docker-desktop)
|
||||
|
||||
### Setup Docker Image
|
||||
### a. Run environment
|
||||
|
||||
Run the following commands to build and start the Docker image:
|
||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose-build-local.yml build
|
||||
docker-compose -f docker/docker-compose-build-local.yml up
|
||||
docker-compose -f docker/docker-compose.yml up
|
||||
```
|
||||
|
||||
### Setup Database
|
||||
#### Setup Database
|
||||
|
||||
Run the following command to setup the database once Ghostfolio is running:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
|
||||
docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:setup
|
||||
```
|
||||
|
||||
### b. Build and run environment
|
||||
|
||||
Run the following commands to build and start the Docker images:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.build.yml build
|
||||
docker-compose -f docker/docker-compose.build.yml up
|
||||
```
|
||||
|
||||
#### Setup Database
|
||||
|
||||
Run the following command to setup the database once Ghostfolio is running:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:setup
|
||||
```
|
||||
|
||||
### Fetch Historical Data
|
||||
@ -112,6 +128,12 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||
1. Click _Sign out_ and check out the _Live Demo_
|
||||
|
||||
### Finalization
|
||||
|
||||
1. Create a new user via _Get Started_
|
||||
1. Assign the role `ADMIN` to this user (directly in the database)
|
||||
1. Delete the original _Admin_ (directly in the database)
|
||||
|
||||
### Migrate Database
|
||||
|
||||
With the following command you can keep your database schema in sync after a Ghostfolio version update:
|
||||
@ -131,9 +153,7 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `cd docker`
|
||||
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `cd -` to go back to the project root directory
|
||||
1. Run `docker-compose -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||
1. Start server and client (see [_Development_](#Development))
|
||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
@ -22,16 +22,18 @@ import {
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { isDate, isValid } from 'date-fns';
|
||||
import { isDate } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AdminService } from './admin.service';
|
||||
import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
|
||||
@Controller('admin')
|
||||
export class AdminController {
|
||||
public constructor(
|
||||
private readonly adminService: AdminService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@ -173,7 +175,7 @@ export class AdminController {
|
||||
@Get('market-data/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getMarketDataBySymbol(
|
||||
@Param('symbol') symbol
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<AdminMarketDataDetails> {
|
||||
if (
|
||||
!hasPermission(
|
||||
@ -190,6 +192,39 @@ export class AdminController {
|
||||
return this.adminService.getMarketDataBySymbol(symbol);
|
||||
}
|
||||
|
||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async update(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string,
|
||||
@Body() data: UpdateMarketDataDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const date = new Date(dateString);
|
||||
|
||||
return this.marketDataService.updateMarketData({
|
||||
data: { ...data, dataSource },
|
||||
where: {
|
||||
date_symbol: {
|
||||
date,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Put('settings/:key')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateProperty(
|
||||
|
6
apps/api/src/app/admin/update-market-data.dto.ts
Normal file
6
apps/api/src/app/admin/update-market-data.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsNumber } from 'class-validator';
|
||||
|
||||
export class UpdateMarketDataDto {
|
||||
@IsNumber()
|
||||
marketPrice: number;
|
||||
}
|
@ -7,6 +7,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SLACK_COMMUNITY_USERS,
|
||||
PROPERTY_STRIPE_CONFIG,
|
||||
PROPERTY_SYSTEM_MESSAGE
|
||||
} from '@ghostfolio/common/config';
|
||||
@ -187,6 +188,12 @@ export class InfoService {
|
||||
});
|
||||
}
|
||||
|
||||
private async countSlackCommunityUsers() {
|
||||
return (await this.propertyService.getByKey(
|
||||
PROPERTY_SLACK_COMMUNITY_USERS
|
||||
)) as string;
|
||||
}
|
||||
|
||||
private getDemoAuthToken() {
|
||||
return this.jwtService.sign({
|
||||
id: InfoService.DEMO_USER_ID
|
||||
@ -218,19 +225,19 @@ export class InfoService {
|
||||
} catch {}
|
||||
|
||||
const activeUsers1d = await this.countActiveUsers(1);
|
||||
const activeUsers7d = await this.countActiveUsers(7);
|
||||
const activeUsers30d = await this.countActiveUsers(30);
|
||||
const newUsers30d = await this.countNewUsers(30);
|
||||
const gitHubContributors = await this.countGitHubContributors();
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||
|
||||
statistics = {
|
||||
activeUsers1d,
|
||||
activeUsers7d,
|
||||
activeUsers30d,
|
||||
gitHubContributors,
|
||||
gitHubStargazers,
|
||||
newUsers30d
|
||||
newUsers30d,
|
||||
slackCommunityUsers
|
||||
};
|
||||
|
||||
await this.redisCacheService.set(
|
||||
|
@ -66,28 +66,21 @@ export class OrderController {
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let orders = await this.orderService.orders({
|
||||
include: {
|
||||
Account: {
|
||||
include: {
|
||||
Platform: true
|
||||
}
|
||||
},
|
||||
SymbolProfile: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId: impersonationUserId || this.request.user.id }
|
||||
let orders = await this.orderService.getOrders({
|
||||
includeDrafts: true,
|
||||
userId: impersonationUserId || this.request.user.id
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
||||
orders = nullifyValuesInObjects(orders, [
|
||||
'fee',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value'
|
||||
]);
|
||||
}
|
||||
|
||||
return orders;
|
||||
|
@ -3,7 +3,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
||||
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
@ -82,11 +83,13 @@ export class OrderService {
|
||||
});
|
||||
}
|
||||
|
||||
public getOrders({
|
||||
public async getOrders({
|
||||
includeDrafts = false,
|
||||
types,
|
||||
userId
|
||||
}: {
|
||||
includeDrafts?: boolean;
|
||||
types?: TypeOfOrder[];
|
||||
userId: string;
|
||||
}) {
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
@ -95,15 +98,39 @@ export class OrderService {
|
||||
where.isDraft = false;
|
||||
}
|
||||
|
||||
return this.orders({
|
||||
where,
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Account: true,
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
SymbolProfile: true
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
if (types) {
|
||||
where.OR = types.map((type) => {
|
||||
return {
|
||||
type: {
|
||||
equals: type
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
await this.orders({
|
||||
where,
|
||||
include: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
Account: {
|
||||
include: {
|
||||
Platform: true
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
SymbolProfile: true
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
})
|
||||
).map((order) => {
|
||||
return {
|
||||
...order,
|
||||
value: new Big(order.quantity)
|
||||
.mul(order.unitPrice)
|
||||
.plus(order.fee)
|
||||
.toNumber()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface PortfolioOrder {
|
||||
@ -10,6 +9,6 @@ export interface PortfolioOrder {
|
||||
name: string;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
type: OrderType;
|
||||
type: TypeOfOrder;
|
||||
unitPrice: Big;
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
@ -16,6 +17,7 @@ export interface PortfolioPositionDetail {
|
||||
name: string;
|
||||
netPerformance: number;
|
||||
netPerformancePercent: number;
|
||||
orders: OrderWithAccount[];
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
transactionCount: number;
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
@ -23,6 +22,20 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
switch (symbol) {
|
||||
case 'AMZN':
|
||||
return { marketPrice: 2021.99 };
|
||||
case 'BALN.SW':
|
||||
if (isSameDay(parseDate('2021-11-12'), date)) {
|
||||
return { marketPrice: 146 };
|
||||
} else if (isSameDay(parseDate('2021-11-22'), date)) {
|
||||
return { marketPrice: 142.9 };
|
||||
} else if (isSameDay(parseDate('2021-11-26'), date)) {
|
||||
return { marketPrice: 139.9 };
|
||||
} else if (isSameDay(parseDate('2021-11-30'), date)) {
|
||||
return { marketPrice: 136.6 };
|
||||
} else if (isSameDay(parseDate('2021-12-18'), date)) {
|
||||
return { marketPrice: 143.9 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
case 'MFA':
|
||||
if (isSameDay(parseDate('2010-12-31'), date)) {
|
||||
return { marketPrice: 1 };
|
||||
@ -46,10 +59,10 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
|
||||
return { marketPrice: 0 };
|
||||
case 'TSLA':
|
||||
if (isSameDay(parseDate('2021-07-26'), date)) {
|
||||
return { marketPrice: 657.62 };
|
||||
} else if (isSameDay(parseDate('2021-01-02'), date)) {
|
||||
if (isSameDay(parseDate('2021-01-02'), date)) {
|
||||
return { marketPrice: 666.66 };
|
||||
} else if (isSameDay(parseDate('2021-07-26'), date)) {
|
||||
return { marketPrice: 657.62 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
@ -63,6 +76,7 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
)
|
||||
.toNumber()
|
||||
};
|
||||
|
||||
default:
|
||||
return { marketPrice: 0 };
|
||||
}
|
||||
@ -73,20 +87,10 @@ jest.mock('./current-rate.service', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
getValue: ({
|
||||
currency,
|
||||
date,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: GetValueParams) => {
|
||||
getValue: ({ date, symbol }: GetValueParams) => {
|
||||
return Promise.resolve(mockGetValue(symbol, date));
|
||||
},
|
||||
getValues: ({
|
||||
currencies,
|
||||
dateQuery,
|
||||
dataGatheringItems,
|
||||
userCurrency
|
||||
}: GetValuesParams) => {
|
||||
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
|
||||
const result = [];
|
||||
if (dateQuery.lt) {
|
||||
for (
|
||||
@ -155,7 +159,7 @@ describe('PortfolioCalculator', () => {
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('10'),
|
||||
symbol: 'VTI',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('144.38'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -166,7 +170,7 @@ describe('PortfolioCalculator', () => {
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('10'),
|
||||
symbol: 'VTI',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('147.99'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -177,7 +181,7 @@ describe('PortfolioCalculator', () => {
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('15'),
|
||||
symbol: 'VTI',
|
||||
type: OrderType.Sell,
|
||||
type: 'SELL',
|
||||
unitPrice: new Big('151.41'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -248,7 +252,7 @@ describe('PortfolioCalculator', () => {
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('10'),
|
||||
symbol: 'VTI',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('144.38'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -259,7 +263,7 @@ describe('PortfolioCalculator', () => {
|
||||
name: 'Something else',
|
||||
quantity: new Big('10'),
|
||||
symbol: 'VTX',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('147.99'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -270,7 +274,7 @@ describe('PortfolioCalculator', () => {
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'VTI',
|
||||
type: OrderType.Sell,
|
||||
type: 'SELL',
|
||||
unitPrice: new Big('151.41'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -360,7 +364,7 @@ describe('PortfolioCalculator', () => {
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('20'),
|
||||
symbol: 'VTI',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('197.15'),
|
||||
fee: new Big(0)
|
||||
}
|
||||
@ -462,7 +466,7 @@ describe('PortfolioCalculator', () => {
|
||||
name: 'Amazon.com, Inc.',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'AMZN',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('2021.99'),
|
||||
fee: new Big(0)
|
||||
}
|
||||
@ -617,7 +621,7 @@ describe('PortfolioCalculator', () => {
|
||||
name: 'Amazon.com, Inc.',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'AMZN',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('2021.99'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -628,7 +632,7 @@ describe('PortfolioCalculator', () => {
|
||||
name: 'Amazon.com, Inc.',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'AMZN',
|
||||
type: OrderType.Sell,
|
||||
type: 'SELL',
|
||||
unitPrice: new Big('2412.23'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -1487,6 +1491,126 @@ describe('PortfolioCalculator', () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('with BALN.SW', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
currentRateService,
|
||||
'CHF'
|
||||
);
|
||||
|
||||
// date,type,ticker,currency,units,price,fee
|
||||
portfolioCalculator.setTransactionPoints([
|
||||
// 12.11.2021,BUY,BALN.SW,CHF,2.00,146.00,1.65
|
||||
{
|
||||
date: '2021-11-12',
|
||||
items: [
|
||||
{
|
||||
quantity: new Big('2'),
|
||||
symbol: 'BALN.SW',
|
||||
investment: new Big('292'),
|
||||
currency: 'CHF',
|
||||
dataSource: DataSource.YAHOO,
|
||||
firstBuyDate: '2021-11-12',
|
||||
fee: new Big('1.65'),
|
||||
transactionCount: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
// HWR: (End Value - (Initial Value + Cash Flow)) / (Initial Value + Cash Flow)
|
||||
// End Value: 142.9 * 2 = 285.8
|
||||
// Initial Value: 292 (Investment)
|
||||
// Cash Flow: 0
|
||||
// HWR_n0: (285.8 - 292) / 292 = -0.021232877
|
||||
|
||||
// 22.11.2021,BUY,BALN.SW,CHF,7.00,142.90,5.75
|
||||
{
|
||||
date: '2021-11-22',
|
||||
items: [
|
||||
{
|
||||
quantity: new Big('9'), // 7 + 2
|
||||
symbol: 'BALN.SW',
|
||||
investment: new Big('1292.3'), // 142.9 * 7 + 146 * 2
|
||||
currency: 'CHF',
|
||||
dataSource: DataSource.YAHOO,
|
||||
firstBuyDate: '2021-11-12',
|
||||
fee: new Big('7.4'), // 1.65 + 5.75
|
||||
transactionCount: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
// HWR: (End Value - (Initial Value + Cash Flow)) / (Initial Value + Cash Flow)
|
||||
// End Value: 139.9 * 9 = 1259.1
|
||||
// Initial Value: 285.8 (End Value n0)
|
||||
// Cash Flow: 1000.3
|
||||
// Initial Value + Cash Flow: 285.8 + 1000.3 = 1286.1
|
||||
// HWR_n1: (1259.1 - 1286.1) / 1286.1 = -0.020993702
|
||||
|
||||
// 26.11.2021,BUY,BALN.SW,CHF,3.00,139.90,2.40
|
||||
{
|
||||
date: '2021-11-26',
|
||||
items: [
|
||||
{
|
||||
quantity: new Big('12'), // 3 + 7 + 2
|
||||
symbol: 'BALN.SW',
|
||||
investment: new Big('1712'), // 139.9 * 3 + 142.9 * 7 + 146 * 2
|
||||
currency: 'CHF',
|
||||
dataSource: DataSource.YAHOO,
|
||||
firstBuyDate: '2021-11-12',
|
||||
fee: new Big('9.8'), // 2.40 + 1.65 + 5.75
|
||||
transactionCount: 3
|
||||
}
|
||||
]
|
||||
},
|
||||
// HWR: (End Value - (Initial Value + Cash Flow)) / (Initial Value + Cash Flow)
|
||||
// End Value: 136.6 * 12 = 1639.2
|
||||
// Initial Value: 1259.1 (End Value n1)
|
||||
// Cash Flow: 139.9 * 3 = 419.7
|
||||
// Initial Value + Cash Flow: 1259.1 + 419.7 = 1678.8
|
||||
// HWR_n2: (1639.2 - 1678.8) / 1678.8 = -0.023588277
|
||||
|
||||
// 30.11.2021,BUY,BALN.SW,CHF,2.00,136.60,1.55
|
||||
{
|
||||
date: '2021-11-30',
|
||||
items: [
|
||||
{
|
||||
quantity: new Big('14'), // 2 + 3 + 7 + 2
|
||||
symbol: 'BALN.SW',
|
||||
investment: new Big('1985.2'), // 136.6 * 2 + 139.9 * 3 + 142.9 * 7 + 146 * 2
|
||||
currency: 'CHF',
|
||||
dataSource: DataSource.YAHOO,
|
||||
firstBuyDate: '2021-11-12',
|
||||
fee: new Big('11.35'), // 1.55 + 2.40 + 1.65 + 5.75
|
||||
transactionCount: 4
|
||||
}
|
||||
]
|
||||
}
|
||||
// HWR: (End Value - (Initial Value + Cash Flow)) / (Initial Value + Cash Flow)
|
||||
// End Value: 143.9 * 14 = 2014.6
|
||||
// Initial Value: 1639.2 (End Value n2)
|
||||
// Cash Flow: 136.6 * 2 = 273.2
|
||||
// Initial Value + Cash Flow: 1639.2 + 273.2 = 1912.4
|
||||
// HWR_n3: (2014.6 - 1912.4) / 1912.4 = 0.053440703
|
||||
]);
|
||||
|
||||
// HWR_total = 1 - (HWR_n0 + 1) * (HWR_n1 + 1) * (HWR_n2 + 1) * (HWR_n3 + 1)
|
||||
// HWR_total = 1 - (-0.021232877 + 1) * (-0.020993702 + 1) * (-0.023588277 + 1) * (0.053440703 + 1) = 0.014383561
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => new Date(Date.UTC(2021, 11, 18)).getTime()); // 2021-12-18
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2021-11-01')
|
||||
);
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toBeDefined();
|
||||
expect(currentPositions.grossPerformance).toEqual(new Big('29.4'));
|
||||
expect(currentPositions.netPerformance).toEqual(new Big('18.05'));
|
||||
expect(currentPositions.grossPerformancePercentage).toEqual(
|
||||
new Big('-0.01438356164383561644')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculate timeline', () => {
|
||||
@ -2391,7 +2515,7 @@ const ordersMixedSymbols: PortfolioOrder[] = [
|
||||
name: 'Tesla, Inc.',
|
||||
quantity: new Big('50'),
|
||||
symbol: 'TSLA',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('42.97'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -2402,7 +2526,7 @@ const ordersMixedSymbols: PortfolioOrder[] = [
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big('0.5614682'),
|
||||
symbol: 'BTCUSD',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('3562.089535970158'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -2413,7 +2537,7 @@ const ordersMixedSymbols: PortfolioOrder[] = [
|
||||
name: 'Amazon.com, Inc.',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'AMZN',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('2021.99'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -2427,7 +2551,7 @@ const ordersVTI: PortfolioOrder[] = [
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('10'),
|
||||
symbol: 'VTI',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('144.38'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -2438,7 +2562,7 @@ const ordersVTI: PortfolioOrder[] = [
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('10'),
|
||||
symbol: 'VTI',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('147.99'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -2449,7 +2573,7 @@ const ordersVTI: PortfolioOrder[] = [
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('15'),
|
||||
symbol: 'VTI',
|
||||
type: OrderType.Sell,
|
||||
type: 'SELL',
|
||||
unitPrice: new Big('151.41'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -2460,7 +2584,7 @@ const ordersVTI: PortfolioOrder[] = [
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('10'),
|
||||
symbol: 'VTI',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('177.69'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
@ -2471,7 +2595,7 @@ const ordersVTI: PortfolioOrder[] = [
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('10'),
|
||||
symbol: 'VTI',
|
||||
type: OrderType.Buy,
|
||||
type: 'BUY',
|
||||
unitPrice: new Big('203.15'),
|
||||
currency: 'USD',
|
||||
dataSource: DataSource.YAHOO,
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
@ -238,9 +238,7 @@ export class PortfolioCalculator {
|
||||
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
Logger.error(
|
||||
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
||||
);
|
||||
Logger.warn(`Missing value for symbol ${item.symbol} at ${nextDate}`);
|
||||
continue;
|
||||
}
|
||||
let lastInvestment: Big = new Big(0);
|
||||
@ -271,7 +269,7 @@ export class PortfolioCalculator {
|
||||
if (!initialValue) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
Logger.error(
|
||||
Logger.warn(
|
||||
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
||||
);
|
||||
continue;
|
||||
@ -515,7 +513,7 @@ export class PortfolioCalculator {
|
||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
Logger.error(
|
||||
Logger.warn(
|
||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
|
||||
);
|
||||
hasErrors = true;
|
||||
@ -660,14 +658,14 @@ export class PortfolioCalculator {
|
||||
};
|
||||
}
|
||||
|
||||
private getFactor(type: OrderType) {
|
||||
private getFactor(type: TypeOfOrder) {
|
||||
let factor: number;
|
||||
|
||||
switch (type) {
|
||||
case OrderType.Buy:
|
||||
case 'BUY':
|
||||
factor = 1;
|
||||
break;
|
||||
case OrderType.Sell:
|
||||
case 'SELL':
|
||||
factor = -1;
|
||||
break;
|
||||
default:
|
||||
|
@ -51,7 +51,7 @@ export class PortfolioController {
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async findAll(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Res() res: Response
|
||||
): Promise<InvestmentItem[]> {
|
||||
if (
|
||||
@ -87,7 +87,7 @@ export class PortfolioController {
|
||||
@Get('chart')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getChart(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioChart> {
|
||||
@ -98,18 +98,14 @@ export class PortfolioController {
|
||||
|
||||
let chartData = historicalDataContainer.items;
|
||||
|
||||
let hasNullValue = false;
|
||||
let hasError = false;
|
||||
|
||||
chartData.forEach((chartDataItem) => {
|
||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
||||
hasNullValue = true;
|
||||
hasError = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (hasNullValue) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
@ -131,6 +127,7 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
return <any>res.json({
|
||||
hasError,
|
||||
chart: chartData,
|
||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||
@ -140,7 +137,7 @@ export class PortfolioController {
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getDetails(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioDetails> {
|
||||
@ -152,6 +149,8 @@ export class PortfolioController {
|
||||
return <any>res.json({ accounts: {}, holdings: {} });
|
||||
}
|
||||
|
||||
let hasError = false;
|
||||
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(
|
||||
impersonationId,
|
||||
@ -160,7 +159,7 @@ export class PortfolioController {
|
||||
);
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
if (
|
||||
@ -198,43 +197,38 @@ export class PortfolioController {
|
||||
}
|
||||
}
|
||||
|
||||
return <any>res.json({ accounts, holdings });
|
||||
return <any>res.json({ accounts, hasError, holdings });
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPerformance(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPerformance> {
|
||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||
const performanceInformation = await this.portfolioService.getPerformance(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
if (performanceInformation?.hasErrors) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
let performance = performanceInformation.performance;
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
performance = nullifyValuesInObject(performance, [
|
||||
'currentGrossPerformance',
|
||||
'currentValue'
|
||||
]);
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
['currentGrossPerformance', 'currentValue']
|
||||
);
|
||||
}
|
||||
|
||||
return <any>res.json(performance);
|
||||
return <any>res.json(performanceInformation);
|
||||
}
|
||||
|
||||
@Get('positions')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPositions> {
|
||||
@ -243,10 +237,6 @@ export class PortfolioController {
|
||||
range
|
||||
);
|
||||
|
||||
if (result?.hasErrors) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
@ -340,6 +330,7 @@ export class PortfolioController {
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'fees',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
@ -353,7 +344,7 @@ export class PortfolioController {
|
||||
@Get('position/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
let position = await this.portfolioService.getPosition(
|
||||
@ -370,6 +361,7 @@ export class PortfolioController {
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'orders',
|
||||
'quantity',
|
||||
'value'
|
||||
]);
|
||||
@ -387,7 +379,7 @@ export class PortfolioController {
|
||||
@Get('report')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioReport> {
|
||||
if (
|
||||
|
@ -6,7 +6,6 @@ import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfol
|
||||
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
|
||||
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
|
||||
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
||||
@ -21,11 +20,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
|
||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
UNKNOWN_KEY,
|
||||
baseCurrency,
|
||||
ghostfolioCashSymbol
|
||||
} from '@ghostfolio/common/config';
|
||||
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Accounts,
|
||||
@ -393,6 +388,7 @@ export class PortfolioService {
|
||||
name: undefined,
|
||||
netPerformance: undefined,
|
||||
netPerformancePercent: undefined,
|
||||
orders: [],
|
||||
quantity: undefined,
|
||||
symbol: aSymbol,
|
||||
transactionCount: undefined,
|
||||
@ -405,17 +401,21 @@ export class PortfolioService {
|
||||
const positionCurrency = orders[0].currency;
|
||||
const name = orders[0].SymbolProfile?.name ?? '';
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(order.fee),
|
||||
name: order.SymbolProfile?.name,
|
||||
quantity: new Big(order.quantity),
|
||||
symbol: order.symbol,
|
||||
type: <OrderType>order.type,
|
||||
unitPrice: new Big(order.unitPrice)
|
||||
}));
|
||||
const portfolioOrders: PortfolioOrder[] = orders
|
||||
.filter((order) => {
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
})
|
||||
.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(order.fee),
|
||||
name: order.SymbolProfile?.name,
|
||||
quantity: new Big(order.quantity),
|
||||
symbol: order.symbol,
|
||||
type: order.type,
|
||||
unitPrice: new Big(order.unitPrice)
|
||||
}));
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
@ -526,6 +526,7 @@ export class PortfolioService {
|
||||
minPrice,
|
||||
name,
|
||||
netPerformance,
|
||||
orders,
|
||||
transactionCount,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
||||
@ -583,6 +584,7 @@ export class PortfolioService {
|
||||
maxPrice,
|
||||
minPrice,
|
||||
name,
|
||||
orders,
|
||||
averagePrice: 0,
|
||||
currency: currentData[aSymbol]?.currency,
|
||||
firstBuyDate: undefined,
|
||||
@ -731,22 +733,6 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date
|
||||
return isBefore(date, new Date(order.date));
|
||||
})
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.currency,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
})
|
||||
.reduce((previous, current) => previous + current, 0);
|
||||
}
|
||||
|
||||
public async getReport(impersonationId: string): Promise<PortfolioReport> {
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
@ -827,7 +813,7 @@ export class PortfolioService {
|
||||
new FeeRatioInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions.totalInvestment.toNumber(),
|
||||
this.getFees(orders)
|
||||
this.getFees(orders).toNumber()
|
||||
)
|
||||
],
|
||||
{ baseCurrency: currency }
|
||||
@ -846,12 +832,15 @@ export class PortfolioService {
|
||||
userId,
|
||||
currency
|
||||
);
|
||||
const orders = await this.orderService.getOrders({ userId });
|
||||
const fees = this.getFees(orders);
|
||||
const orders = await this.orderService.getOrders({
|
||||
userId
|
||||
});
|
||||
const dividend = this.getDividend(orders).toNumber();
|
||||
const fees = this.getFees(orders).toNumber();
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
|
||||
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
|
||||
const totalBuy = this.getTotalByType(orders, currency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, currency, 'SELL');
|
||||
|
||||
const committedFunds = new Big(totalBuy).sub(totalSell);
|
||||
|
||||
@ -861,14 +850,17 @@ export class PortfolioService {
|
||||
|
||||
return {
|
||||
...performanceInformation.performance,
|
||||
dividend,
|
||||
fees,
|
||||
firstOrderDate,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
cash: balance,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
ordersCount: orders.length,
|
||||
totalBuy: totalBuy,
|
||||
totalSell: totalSell
|
||||
ordersCount: orders.filter((order) => {
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
}).length
|
||||
};
|
||||
}
|
||||
|
||||
@ -941,6 +933,47 @@ export class PortfolioService {
|
||||
return cashPositions;
|
||||
}
|
||||
|
||||
private getDividend(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date and type dividend
|
||||
return (
|
||||
isBefore(date, new Date(order.date)) &&
|
||||
order.type === TypeOfOrder.DIVIDEND
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(order.quantity).mul(order.unitPrice).toNumber(),
|
||||
order.currency,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
}
|
||||
|
||||
private getFees(orders: OrderWithAccount[], date = new Date(0)) {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
// Filter out all orders before given date
|
||||
return isBefore(date, new Date(order.date));
|
||||
})
|
||||
.map((order) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.currency,
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
}
|
||||
|
||||
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
@ -969,7 +1002,11 @@ export class PortfolioService {
|
||||
transactionPoints: TransactionPoint[];
|
||||
orders: OrderWithAccount[];
|
||||
}> {
|
||||
const orders = await this.orderService.getOrders({ includeDrafts, userId });
|
||||
const orders = await this.orderService.getOrders({
|
||||
includeDrafts,
|
||||
userId,
|
||||
types: ['BUY', 'SELL']
|
||||
});
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return { transactionPoints: [], orders: [] };
|
||||
@ -990,7 +1027,7 @@ export class PortfolioService {
|
||||
name: order.SymbolProfile?.name,
|
||||
quantity: new Big(order.quantity),
|
||||
symbol: order.symbol,
|
||||
type: <OrderType>order.type,
|
||||
type: order.type,
|
||||
unitPrice: new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
order.unitPrice,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
@ -12,9 +13,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { isDate, isEmpty } from 'lodash';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
@ -78,4 +79,27 @@ export class SymbolController {
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async gatherSymbolForDate(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<IDataProviderHistoricalResponse> {
|
||||
const date = new Date(dateString);
|
||||
|
||||
if (!isDate(date)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
|
||||
return this.symbolService.getForDate({
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,15 @@
|
||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { subDays } from 'date-fns';
|
||||
import { format, subDays } from 'date-fns';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
@ -32,17 +36,17 @@ export class SymbolService {
|
||||
let historicalData: HistoricalDataItem[];
|
||||
|
||||
if (includeHistoricalData) {
|
||||
const days = 10;
|
||||
const days = 30;
|
||||
|
||||
const marketData = await this.marketDataService.getRange({
|
||||
dateQuery: { gte: subDays(new Date(), days) },
|
||||
symbols: [dataGatheringItem.symbol]
|
||||
});
|
||||
|
||||
historicalData = marketData.map(({ date, marketPrice }) => {
|
||||
historicalData = marketData.map(({ date, marketPrice: value }) => {
|
||||
return {
|
||||
date: date.toISOString(),
|
||||
value: marketPrice
|
||||
value,
|
||||
date: date.toISOString()
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -58,6 +62,27 @@ export class SymbolService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async getForDate({
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<IDataProviderHistoricalResponse> {
|
||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||
[{ dataSource, symbol }],
|
||||
date,
|
||||
date
|
||||
);
|
||||
|
||||
return {
|
||||
marketPrice:
|
||||
historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice
|
||||
};
|
||||
}
|
||||
|
||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const results: { items: LookupItem[] } = { items: [] };
|
||||
|
||||
|
@ -1,8 +0,0 @@
|
||||
export enum OrderType {
|
||||
CorporateAction = 'CORPORATE_ACTION',
|
||||
Bonus = 'BONUS',
|
||||
Buy = 'BUY',
|
||||
Dividend = 'DIVIDEND',
|
||||
Sell = 'SELL',
|
||||
Split = 'SPLIT'
|
||||
}
|
@ -1,8 +1,7 @@
|
||||
import { Account, SymbolProfile } from '@prisma/client';
|
||||
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { IOrder } from '../services/interfaces/interfaces';
|
||||
import { OrderType } from './order-type';
|
||||
|
||||
export class Order {
|
||||
private account: Account;
|
||||
@ -15,7 +14,7 @@ export class Order {
|
||||
private symbol: string;
|
||||
private symbolProfile: SymbolProfile;
|
||||
private total: number;
|
||||
private type: OrderType;
|
||||
private type: TypeOfOrder;
|
||||
private unitPrice: number;
|
||||
|
||||
public constructor(data: IOrder) {
|
||||
|
@ -1,8 +1,11 @@
|
||||
{
|
||||
"1INCH": "1inch",
|
||||
"ALGO": "Algorand",
|
||||
"ATOM": "Cosmos",
|
||||
"AVAX": "Avalanche",
|
||||
"DOT": "Polkadot",
|
||||
"MATIC": "Polygon",
|
||||
"SHIB": "Shiba Inu",
|
||||
"SOL": "Solana",
|
||||
"UNI3": "Uniswap"
|
||||
}
|
||||
|
@ -1,12 +1,11 @@
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
PROPERTY_LAST_DATA_GATHERING,
|
||||
PROPERTY_LOCKED_DATA_GATHERING,
|
||||
ghostfolioFearAndGreedIndexSymbol
|
||||
PROPERTY_LOCKED_DATA_GATHERING
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
differenceInHours,
|
||||
format,
|
||||
@ -17,7 +16,6 @@ import {
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||
@ -29,7 +27,6 @@ export class DataGatheringService {
|
||||
private dataGatheringProgress: number;
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@Inject('DataEnhancers')
|
||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
@ -245,7 +242,7 @@ export class DataGatheringService {
|
||||
try {
|
||||
currentData[symbol] = await dataEnhancer.enhance({
|
||||
response,
|
||||
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol
|
||||
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
|
||||
@ -337,16 +334,25 @@ export class DataGatheringService {
|
||||
?.marketPrice;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: currentDate,
|
||||
marketPrice: lastMarketPrice
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
if (lastMarketPrice) {
|
||||
try {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: currentDate,
|
||||
marketPrice: lastMarketPrice
|
||||
}
|
||||
});
|
||||
} catch {}
|
||||
} else {
|
||||
Logger.warn(
|
||||
`Failed to gather data for symbol ${symbol} at ${format(
|
||||
currentDate,
|
||||
DATE_FORMAT
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Count month one up for iteration
|
||||
currentDate = new Date(
|
||||
@ -448,11 +454,7 @@ export class DataGatheringService {
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
...currencyPairsToGather,
|
||||
...symbolProfilesToGather
|
||||
];
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
|
||||
public async reset() {
|
||||
@ -468,23 +470,27 @@ export class DataGatheringService {
|
||||
});
|
||||
}
|
||||
|
||||
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
|
||||
const benchmarksToGather: IDataGatheringItem[] = [];
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
benchmarksToGather.push({
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
date: startDate,
|
||||
symbol: ghostfolioFearAndGreedIndexSymbol
|
||||
});
|
||||
}
|
||||
|
||||
return benchmarksToGather;
|
||||
}
|
||||
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
// Only consider symbols with incomplete market data for the last
|
||||
// 7 days
|
||||
const symbolsToGather = (
|
||||
await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['symbol'],
|
||||
where: {
|
||||
date: { gt: startDate }
|
||||
}
|
||||
})
|
||||
)
|
||||
.filter((group) => {
|
||||
return group._count < 6;
|
||||
})
|
||||
.map((group) => {
|
||||
return group.symbol;
|
||||
});
|
||||
|
||||
const symbolProfilesToGather = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
@ -494,15 +500,22 @@ export class DataGatheringService {
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
return {
|
||||
...symbolProfile,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
)
|
||||
.filter(({ symbol }) => {
|
||||
return symbolsToGather.includes(symbol);
|
||||
})
|
||||
.map((symbolProfile) => {
|
||||
return {
|
||||
...symbolProfile,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.filter(({ symbol }) => {
|
||||
return symbolsToGather.includes(symbol);
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
@ -511,30 +524,22 @@ export class DataGatheringService {
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
...currencyPairsToGather,
|
||||
...symbolProfilesToGather
|
||||
];
|
||||
return [...currencyPairsToGather, ...symbolProfilesToGather];
|
||||
}
|
||||
|
||||
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const distinctOrders = await this.prismaService.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { dataSource: true, symbol: true }
|
||||
});
|
||||
|
||||
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter(
|
||||
(distinctOrder) => {
|
||||
return (
|
||||
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
||||
distinctOrder.dataSource !== DataSource.RAKUTEN
|
||||
);
|
||||
}
|
||||
);
|
||||
return distinctOrders.filter((distinctOrder) => {
|
||||
return (
|
||||
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
|
||||
distinctOrder.dataSource !== DataSource.RAKUTEN
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private async isDataGatheringNeeded() {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||
|
||||
import { YahooFinanceService } from './yahoo-finance.service';
|
||||
|
||||
jest.mock(
|
||||
@ -13,8 +14,6 @@ jest.mock(
|
||||
return true;
|
||||
case 'DOGEUSD':
|
||||
return true;
|
||||
case 'SOLUSD':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@ -54,9 +53,6 @@ describe('YahooFinanceService', () => {
|
||||
expect(
|
||||
await yahooFinanceService.convertToYahooFinanceSymbol('DOGEUSD')
|
||||
).toEqual('DOGE-USD');
|
||||
expect(
|
||||
await yahooFinanceService.convertToYahooFinanceSymbol('SOL1USD')
|
||||
).toEqual('SOL1-USD');
|
||||
expect(
|
||||
await yahooFinanceService.convertToYahooFinanceSymbol('USDCHF')
|
||||
).toEqual('USDCHF=X');
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
|
||||
import { baseCurrency, UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@ -49,7 +49,6 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
* Currency: USDCHF -> USDCHF=X
|
||||
* Cryptocurrency: BTCUSD -> BTC-USD
|
||||
* DOGEUSD -> DOGE-USD
|
||||
* SOL1USD -> SOL1-USD
|
||||
*/
|
||||
public convertToYahooFinanceSymbol(aSymbol: string) {
|
||||
if (aSymbol.includes(baseCurrency) && aSymbol.length >= 6) {
|
||||
@ -57,9 +56,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return `${aSymbol}=X`;
|
||||
} else if (
|
||||
this.cryptocurrencyService.isCryptocurrency(
|
||||
aSymbol
|
||||
.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
|
||||
.replace('1', '')
|
||||
aSymbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
|
||||
)
|
||||
) {
|
||||
// Add a dash before the last three characters
|
||||
@ -246,9 +243,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return (
|
||||
(quoteType === 'CRYPTOCURRENCY' &&
|
||||
this.cryptocurrencyService.isCryptocurrency(
|
||||
symbol
|
||||
.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
|
||||
.replace('1', '')
|
||||
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
|
||||
)) ||
|
||||
quoteType === 'EQUITY' ||
|
||||
quoteType === 'ETF'
|
||||
|
@ -157,7 +157,12 @@ export class ExchangeRateDataService {
|
||||
await this.prismaService.account.findMany({
|
||||
distinct: ['currency'],
|
||||
orderBy: [{ currency: 'asc' }],
|
||||
select: { currency: true }
|
||||
select: { currency: true },
|
||||
where: {
|
||||
currency: {
|
||||
not: null
|
||||
}
|
||||
}
|
||||
})
|
||||
).forEach((account) => {
|
||||
currencies.push(account.currency);
|
||||
@ -167,7 +172,12 @@ export class ExchangeRateDataService {
|
||||
await this.prismaService.settings.findMany({
|
||||
distinct: ['currency'],
|
||||
orderBy: [{ currency: 'asc' }],
|
||||
select: { currency: true }
|
||||
select: { currency: true },
|
||||
where: {
|
||||
currency: {
|
||||
not: null
|
||||
}
|
||||
}
|
||||
})
|
||||
).forEach((userSettings) => {
|
||||
currencies.push(userSettings.currency);
|
||||
@ -177,7 +187,12 @@ export class ExchangeRateDataService {
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
distinct: ['currency'],
|
||||
orderBy: [{ currency: 'asc' }],
|
||||
select: { currency: true }
|
||||
select: { currency: true },
|
||||
where: {
|
||||
currency: {
|
||||
not: null
|
||||
}
|
||||
}
|
||||
})
|
||||
).forEach((symbolProfile) => {
|
||||
currencies.push(symbolProfile.currency);
|
||||
|
@ -3,11 +3,10 @@ import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
SymbolProfile
|
||||
SymbolProfile,
|
||||
Type as TypeOfOrder
|
||||
} from '@prisma/client';
|
||||
|
||||
import { OrderType } from '../../models/order-type';
|
||||
|
||||
export const MarketState = {
|
||||
closed: 'closed',
|
||||
delayed: 'delayed',
|
||||
@ -24,7 +23,7 @@ export interface IOrder {
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
symbolProfile: SymbolProfile;
|
||||
type: OrderType;
|
||||
type: TypeOfOrder;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { MarketData, Prisma } from '@prisma/client';
|
||||
import { DataSource, MarketData, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class MarketDataService {
|
||||
@ -65,4 +66,22 @@ export class MarketDataService {
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async updateMarketData(params: {
|
||||
data: { dataSource: DataSource } & UpdateMarketDataDto;
|
||||
where: Prisma.MarketDataWhereUniqueInput;
|
||||
}): Promise<MarketData> {
|
||||
const { data, where } = params;
|
||||
|
||||
return this.prismaService.marketData.upsert({
|
||||
where,
|
||||
create: {
|
||||
dataSource: data.dataSource,
|
||||
date: where.date_symbol.date,
|
||||
marketPrice: data.marketPrice,
|
||||
symbol: where.date_symbol.symbol
|
||||
},
|
||||
update: { marketPrice: data.marketPrice }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -89,7 +89,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.tokenStorageService.signOut();
|
||||
this.userService.remove();
|
||||
|
||||
window.location.reload();
|
||||
this.router.navigate(['/']);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
|
@ -1,4 +1,10 @@
|
||||
<div class="py-2">
|
||||
<div>
|
||||
<gf-line-chart
|
||||
class="mb-4"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="true"
|
||||
></gf-line-chart>
|
||||
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
|
||||
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
||||
<div class="align-items-center d-flex flex-grow-1 px-1">
|
||||
|
@ -14,6 +14,10 @@
|
||||
margin-right: 0.25rem;
|
||||
width: 0.5rem;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
&.valid {
|
||||
background-color: var(--danger);
|
||||
}
|
||||
|
@ -1,13 +1,16 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnInit
|
||||
OnInit,
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format, isBefore, isValid, parse } from 'date-fns';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
@ -26,9 +29,12 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
@Input() marketData: MarketData[];
|
||||
@Input() symbol: string;
|
||||
|
||||
@Output() marketDataChanged = new EventEmitter<boolean>();
|
||||
|
||||
public days = Array(31);
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||
public deviceType: string;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public marketDataByMonth: {
|
||||
[yearMonth: string]: { [day: string]: MarketData & { day: number } };
|
||||
} = {};
|
||||
@ -45,6 +51,12 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
public ngOnInit() {}
|
||||
|
||||
public ngOnChanges() {
|
||||
this.historicalDataItems = this.marketData.map((marketDataItem) => {
|
||||
return {
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value: marketDataItem.marketPrice
|
||||
};
|
||||
});
|
||||
this.marketDataByMonth = {};
|
||||
|
||||
for (const marketDataItem of this.marketData) {
|
||||
@ -93,7 +105,9 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
.subscribe(({ withRefresh }) => {
|
||||
this.marketDataChanged.next(withRefresh);
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
|
||||
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
|
||||
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
|
||||
@ -7,7 +8,7 @@ import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/mark
|
||||
@NgModule({
|
||||
declarations: [AdminMarketDataDetailComponent],
|
||||
exports: [AdminMarketDataDetailComponent],
|
||||
imports: [CommonModule, GfMarketDataDetailDialogModule],
|
||||
imports: [CommonModule, GfLineChartModule, GfMarketDataDetailDialogModule],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -7,7 +7,6 @@ import {
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { MarketData } from '@prisma/client';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
import { MarketDataDetailDialogParams } from './interfaces/interfaces';
|
||||
@ -32,24 +31,38 @@ export class MarketDataDetailDialog implements OnDestroy {
|
||||
public ngOnInit() {}
|
||||
|
||||
public onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
this.dialogRef.close({ withRefresh: false });
|
||||
}
|
||||
|
||||
public onGatherData() {
|
||||
public onFetchSymbolForDate() {
|
||||
this.adminService
|
||||
.gatherSymbol({
|
||||
.fetchSymbolForDate({
|
||||
dataSource: this.data.dataSource,
|
||||
date: this.data.date,
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((marketData: MarketData) => {
|
||||
this.data.marketPrice = marketData.marketPrice;
|
||||
.subscribe(({ marketPrice }) => {
|
||||
this.data.marketPrice = marketPrice;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public onUpdate() {
|
||||
this.adminService
|
||||
.putMarketData({
|
||||
dataSource: this.data.dataSource,
|
||||
date: this.data.date,
|
||||
marketData: { marketPrice: this.data.marketPrice },
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.dialogRef.close({ withRefresh: true });
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -21,22 +21,30 @@
|
||||
<mat-datepicker #date disabled="true"></mat-datepicker>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="align-items-center d-flex">
|
||||
<mat-form-field appearance="outline" class="flex-grow-1 mr-2">
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Market Price</mat-label>
|
||||
<input
|
||||
matInput
|
||||
name="marketPrice"
|
||||
readonly
|
||||
type="number"
|
||||
[(ngModel)]="data.marketPrice"
|
||||
/>
|
||||
<button
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
title="Fetch market price"
|
||||
(click)="onFetchSymbolForDate()"
|
||||
>
|
||||
<ion-icon class="text-muted" name="refresh-outline"></ion-icon>
|
||||
</button>
|
||||
</mat-form-field>
|
||||
<button color="accent" i18n mat-flat-button (click)="onGatherData()">
|
||||
Gather Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="justify-content-end" mat-dialog-actions>
|
||||
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||
<button color="primary" i18n mat-flat-button (click)="onUpdate()">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
@ -68,6 +68,13 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
public onMarketDataChanged(withRefresh: boolean = false) {
|
||||
if (withRefresh) {
|
||||
this.fetchAdminMarketData();
|
||||
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
|
||||
}
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -4,7 +4,6 @@
|
||||
<table class="gf-table w-100">
|
||||
<thead>
|
||||
<tr class="mat-header-row">
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>First Transaction</th>
|
||||
@ -17,7 +16,6 @@
|
||||
class="cursor-pointer mat-row"
|
||||
(click)="setCurrentSymbol(item.symbol)"
|
||||
>
|
||||
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.dataSource}}</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
@ -44,12 +42,12 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
|
||||
<td></td>
|
||||
<td colspan="4">
|
||||
<td class="p-1" colspan="4">
|
||||
<gf-admin-market-data-detail
|
||||
[dataSource]="item.dataSource"
|
||||
[marketData]="marketDataDetails"
|
||||
[symbol]="item.symbol"
|
||||
(marketDataChanged)="onMarketDataChanged($event)"
|
||||
></gf-admin-market-data-detail>
|
||||
</td>
|
||||
</tr>
|
||||
|
@ -5,7 +5,7 @@
|
||||
<table class="gf-table">
|
||||
<thead>
|
||||
<tr class="mat-header-row">
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
||||
<th class="mat-header-cell px-1 py-2 text-right">#</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||
Registration
|
||||
|
@ -1,5 +1,6 @@
|
||||
<button
|
||||
*ngIf="deviceType === 'mobile'"
|
||||
class="mt-2"
|
||||
mat-button
|
||||
(click)="onClickCloseButton()"
|
||||
>
|
||||
|
@ -1,4 +1,7 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 0;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
@ -1,10 +1,14 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import {
|
||||
RANGE,
|
||||
SettingsStorageService
|
||||
} from '@ghostfolio/client/services/settings-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
||||
import { Position, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
@ -19,6 +23,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
})
|
||||
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
public dateRange: DateRange;
|
||||
public dateRangeOptions = defaultDateRangeOptions;
|
||||
public deviceType: string;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public positions: Position[];
|
||||
@ -33,9 +38,20 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private settingsStorageService: SettingsStorageService,
|
||||
private userService: UserService
|
||||
) {
|
||||
route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['positionDetailDialog'] && params['symbol']) {
|
||||
this.openDialog(params['symbol']);
|
||||
}
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
@ -64,12 +80,41 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
this.update();
|
||||
}
|
||||
|
||||
public onChangeDateRange(aDateRange: DateRange) {
|
||||
this.dateRange = aDateRange;
|
||||
this.settingsStorageService.setSetting(RANGE, this.dateRange);
|
||||
this.update();
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private openDialog(aSymbol: string): void {
|
||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
baseCurrency: this.user?.settings?.baseCurrency,
|
||||
deviceType: this.deviceType,
|
||||
locale: this.user?.settings?.locale,
|
||||
symbol: aSymbol
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
});
|
||||
}
|
||||
|
||||
private update() {
|
||||
this.positions = undefined;
|
||||
|
||||
this.dataService
|
||||
.fetchPositions({ range: this.dateRange })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
|
@ -1,4 +1,12 @@
|
||||
<div class="container justify-content-center pb-3 px-3">
|
||||
<div class="container justify-content-center p-3">
|
||||
<div class="mb-3 text-center">
|
||||
<gf-toggle
|
||||
[defaultValue]="dateRange"
|
||||
[isLoading]="positions === undefined"
|
||||
[options]="dateRangeOptions"
|
||||
(change)="onChangeDateRange($event.value)"
|
||||
></gf-toggle>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
|
||||
<mat-card class="p-0">
|
||||
|
@ -3,7 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
|
||||
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
|
||||
import { HomeHoldingsComponent } from './home-holdings.component';
|
||||
|
||||
@ -12,7 +14,9 @@ import { HomeHoldingsComponent } from './home-holdings.component';
|
||||
exports: [],
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfPositionDetailDialogModule,
|
||||
GfPositionsModule,
|
||||
GfToggleModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
RouterModule
|
||||
|
@ -12,7 +12,7 @@
|
||||
<div class="no-gutters row w-100">
|
||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||
<div class="mb-2 text-center text-muted">
|
||||
<small i18n>Last 10 Days</small>
|
||||
<small i18n>Last 30 Days</small>
|
||||
</div>
|
||||
<gf-line-chart
|
||||
class="mb-5"
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import {
|
||||
@ -7,6 +6,7 @@ import {
|
||||
SettingsStorageService
|
||||
} from '@ghostfolio/client/services/settings-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
||||
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
@ -21,14 +21,9 @@ import { takeUntil } from 'rxjs/operators';
|
||||
})
|
||||
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
public dateRange: DateRange;
|
||||
public dateRangeOptions: ToggleOption[] = [
|
||||
{ label: 'Today', value: '1d' },
|
||||
{ label: 'YTD', value: 'ytd' },
|
||||
{ label: '1Y', value: '1y' },
|
||||
{ label: '5Y', value: '5y' },
|
||||
{ label: 'Max', value: 'max' }
|
||||
];
|
||||
public dateRangeOptions = defaultDateRangeOptions;
|
||||
public deviceType: string;
|
||||
public hasError: boolean;
|
||||
public hasImpersonationId: boolean;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public isAllTimeHigh: boolean;
|
||||
@ -116,7 +111,8 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
.fetchPortfolioPerformance({ range: this.dateRange })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((response) => {
|
||||
this.performance = response;
|
||||
this.hasError = response.hasErrors;
|
||||
this.performance = response.performance;
|
||||
this.isLoadingPerformance = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
@ -1,15 +1,5 @@
|
||||
<div
|
||||
class="
|
||||
align-items-center
|
||||
container
|
||||
d-flex
|
||||
flex-column
|
||||
h-100
|
||||
justify-content-center
|
||||
overview
|
||||
p-0
|
||||
position-relative
|
||||
"
|
||||
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
|
||||
>
|
||||
<div class="row w-100">
|
||||
<div class="chart-container col">
|
||||
@ -37,6 +27,8 @@
|
||||
<gf-portfolio-performance
|
||||
class="pb-4"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasError]="hasError"
|
||||
[isAllTimeHigh]="isAllTimeHigh"
|
||||
[isAllTimeLow]="isAllTimeLow"
|
||||
[isLoading]="isLoadingPerformance"
|
||||
|
@ -1,12 +1,15 @@
|
||||
<div class="container p-0">
|
||||
<div
|
||||
class="no-gutters row"
|
||||
[ngClass]="{
|
||||
'text-danger': isAllTimeLow,
|
||||
'text-success': isAllTimeHigh
|
||||
}"
|
||||
>
|
||||
<div class="flex-grow-1"></div>
|
||||
<div class="no-gutters row">
|
||||
<div
|
||||
class="flex-grow-1 status text-muted text-right"
|
||||
[title]="
|
||||
hasError
|
||||
? 'Sorry! Our data provider partner is experiencing the hiccups.'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<ion-icon *ngIf="hasError" name="alert-circle-outline"></ion-icon>
|
||||
</div>
|
||||
<div *ngIf="isLoading" class="align-items-center d-flex">
|
||||
<ngx-skeleton-loader
|
||||
animation="pulse"
|
||||
@ -20,6 +23,10 @@
|
||||
<div
|
||||
class="display-4 font-weight-bold m-0 text-center value-container"
|
||||
[hidden]="isLoading"
|
||||
[ngClass]="{
|
||||
'text-danger': isAllTimeLow,
|
||||
'text-success': isAllTimeHigh
|
||||
}"
|
||||
>
|
||||
<span #value id="value"></span>
|
||||
</div>
|
||||
|
@ -1,6 +1,10 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.status {
|
||||
font-size: 1.33rem;
|
||||
}
|
||||
|
||||
.value-container {
|
||||
#value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
@ -19,6 +19,8 @@ import { isNumber } from 'lodash';
|
||||
})
|
||||
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
@Input() baseCurrency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() hasError: boolean;
|
||||
@Input() isAllTimeHigh: boolean;
|
||||
@Input() isAllTimeLow: boolean;
|
||||
@Input() isLoading: boolean;
|
||||
@ -44,7 +46,11 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
this.unit = this.baseCurrency;
|
||||
|
||||
new CountUp('value', this.performance?.currentValue, {
|
||||
decimalPlaces: 2,
|
||||
decimalPlaces:
|
||||
this.deviceType === 'mobile' &&
|
||||
this.performance?.currentValue >= 100000
|
||||
? 0
|
||||
: 2,
|
||||
duration: 1,
|
||||
separator: `'`
|
||||
}).start();
|
||||
|
@ -169,4 +169,18 @@
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col"><hr /></div>
|
||||
</div>
|
||||
<div class="row px-3 py-1">
|
||||
<div class="d-flex flex-grow-1" i18n>Dividend</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : summary?.dividend"
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -3,11 +3,13 @@ import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { AssetSubClass } from '@prisma/client';
|
||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||
@ -23,7 +25,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
|
||||
templateUrl: 'position-detail-dialog.html',
|
||||
styleUrls: ['./position-detail-dialog.component.scss']
|
||||
})
|
||||
export class PositionDetailDialog implements OnDestroy {
|
||||
export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
public assetSubClass: AssetSubClass;
|
||||
public averagePrice: number;
|
||||
public benchmarkDataItems: LineChartItem[];
|
||||
@ -39,6 +41,7 @@ export class PositionDetailDialog implements OnDestroy {
|
||||
public name: string;
|
||||
public netPerformance: number;
|
||||
public netPerformancePercent: number;
|
||||
public orders: OrderWithAccount[];
|
||||
public quantity: number;
|
||||
public quantityPrecision = 2;
|
||||
public symbol: string;
|
||||
@ -52,9 +55,11 @@ export class PositionDetailDialog implements OnDestroy {
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<PositionDetailDialog>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
|
||||
) {
|
||||
) {}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.dataService
|
||||
.fetchPositionDetail(data.symbol)
|
||||
.fetchPositionDetail(this.data.symbol)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(
|
||||
({
|
||||
@ -72,6 +77,7 @@ export class PositionDetailDialog implements OnDestroy {
|
||||
name,
|
||||
netPerformance,
|
||||
netPerformancePercent,
|
||||
orders,
|
||||
quantity,
|
||||
symbol,
|
||||
transactionCount,
|
||||
@ -104,6 +110,7 @@ export class PositionDetailDialog implements OnDestroy {
|
||||
this.name = name;
|
||||
this.netPerformance = netPerformance;
|
||||
this.netPerformancePercent = netPerformancePercent;
|
||||
this.orders = orders;
|
||||
this.quantity = quantity;
|
||||
this.symbol = symbol;
|
||||
this.transactionCount = transactionCount;
|
||||
|
@ -124,6 +124,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<gf-transactions-table
|
||||
*ngIf="orders?.length > 0"
|
||||
[baseCurrency]="data.baseCurrency"
|
||||
[deviceType]="data.deviceType"
|
||||
[hasPermissionToCreateOrder]="false"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToImportOrders]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="data.locale"
|
||||
[showActions]="false"
|
||||
[showSymbolColumn]="false"
|
||||
[transactions]="orders"
|
||||
></gf-transactions-table>
|
||||
</div>
|
||||
|
||||
<gf-dialog-footer
|
||||
|
@ -2,12 +2,13 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
|
||||
import { GfTransactionsTableModule } from '@ghostfolio/client/components/transactions-table/transactions-table.module';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module';
|
||||
import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module';
|
||||
import { PositionDetailDialog } from './position-detail-dialog.component';
|
||||
|
||||
@NgModule({
|
||||
@ -18,6 +19,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
|
||||
GfDialogFooterModule,
|
||||
GfDialogHeaderModule,
|
||||
GfLineChartModule,
|
||||
GfTransactionsTableModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatDialogModule,
|
||||
|
@ -5,14 +5,9 @@ import {
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { Position } from '@ghostfolio/common/interfaces';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { PositionDetailDialog } from './position-detail-dialog/position-detail-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-position',
|
||||
@ -32,23 +27,7 @@ export class PositionComponent implements OnDestroy, OnInit {
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router
|
||||
) {
|
||||
route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (
|
||||
params['positionDetailDialog'] &&
|
||||
params['symbol'] &&
|
||||
params['symbol'] === this.position?.symbol
|
||||
) {
|
||||
this.openDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
public constructor() {}
|
||||
|
||||
public ngOnInit() {}
|
||||
|
||||
@ -56,25 +35,4 @@ export class PositionComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private openDialog(): void {
|
||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
baseCurrency: this.baseCurrency,
|
||||
deviceType: this.deviceType,
|
||||
locale: this.locale,
|
||||
symbol: this.position?.symbol
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -14,13 +14,12 @@ import { MatPaginator } from '@angular/material/paginator';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import { AssetClass, Order as OrderModel } from '@prisma/client';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-positions-table',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
@ -7,12 +7,12 @@ import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
import { MatTableModule } from '@angular/material/table';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfPositionDetailDialogModule } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.module';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
|
||||
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
||||
import { PositionsTableComponent } from './positions-table.component';
|
||||
|
||||
|
@ -8,8 +8,7 @@ import {
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
|
||||
import { ToggleOption } from './interfaces/toggle-option.type';
|
||||
import { ToggleOption } from '@ghostfolio/common/types';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-toggle',
|
||||
|
@ -1,4 +1,8 @@
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="w-100"
|
||||
[ngClass]="{ 'd-none': !hasPermissionToFilter }"
|
||||
>
|
||||
<ion-icon class="mr-1" matPrefix name="search-outline"></ion-icon>
|
||||
<mat-chip-list #chipList aria-label="Search keywords">
|
||||
<mat-chip
|
||||
@ -73,11 +77,15 @@
|
||||
<td mat-cell *matCellDef="let element" class="px-1">
|
||||
<div
|
||||
class="d-inline-flex p-1 type-badge"
|
||||
[ngClass]="element.type == 'BUY' ? 'buy' : 'sell'"
|
||||
[ngClass]="{
|
||||
buy: element.type === 'BUY',
|
||||
dividend: element.type === 'DIVIDEND',
|
||||
sell: element.type === 'SELL'
|
||||
}"
|
||||
>
|
||||
<ion-icon
|
||||
[name]="
|
||||
element.type === 'BUY'
|
||||
element.type === 'BUY' || element.type === 'DIVIDEND'
|
||||
? 'arrow-forward-circle-outline'
|
||||
: 'arrow-back-circle-outline'
|
||||
"
|
||||
@ -179,6 +187,27 @@
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="value">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="justify-content-end px-1"
|
||||
i18n
|
||||
mat-header-cell
|
||||
mat-sort-header
|
||||
>
|
||||
Value
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : element.value"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="account">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<span class="d-none d-lg-block" i18n>Account</span>
|
||||
@ -254,12 +283,13 @@
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
mat-row
|
||||
(click)="
|
||||
!row.isDraft &&
|
||||
hasPermissionToOpenDetails &&
|
||||
!row.isDraft &&
|
||||
onOpenPositionDialog({
|
||||
symbol: row.symbol
|
||||
})
|
||||
"
|
||||
[ngClass]="{ 'is-draft': row.isDraft }"
|
||||
[ngClass]="{ 'cursor-pointer': hasPermissionToOpenDetails && !row.isDraft }"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
|
@ -24,10 +24,6 @@
|
||||
}
|
||||
|
||||
.mat-row {
|
||||
&:not(.is-draft) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
background-color: rgba(var(--palette-foreground-text), 0.05);
|
||||
border-radius: 1rem;
|
||||
@ -41,6 +37,10 @@
|
||||
color: var(--green);
|
||||
}
|
||||
|
||||
&.dividend {
|
||||
color: var(--blue);
|
||||
}
|
||||
|
||||
&.sell {
|
||||
color: var(--orange);
|
||||
}
|
||||
|
@ -17,18 +17,15 @@ import {
|
||||
MatAutocompleteSelectedEvent
|
||||
} from '@angular/material/autocomplete';
|
||||
import { MatChipInputEvent } from '@angular/material/chips';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Router } from '@angular/router';
|
||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { endOfToday, format, isAfter } from 'date-fns';
|
||||
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { PositionDetailDialog } from '../position/position-detail-dialog/position-detail-dialog.component';
|
||||
|
||||
const SEARCH_PLACEHOLDER = 'Search for account, currency, symbol or type...';
|
||||
const SEARCH_STRING_SEPARATOR = ',';
|
||||
|
||||
@ -44,9 +41,12 @@ export class TransactionsTableComponent
|
||||
@Input() baseCurrency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() hasPermissionToCreateOrder: boolean;
|
||||
@Input() hasPermissionToFilter = true;
|
||||
@Input() hasPermissionToImportOrders: boolean;
|
||||
@Input() hasPermissionToOpenDetails = true;
|
||||
@Input() locale: string;
|
||||
@Input() showActions: boolean;
|
||||
@Input() showSymbolColumn = true;
|
||||
@Input() transactions: OrderWithAccount[];
|
||||
|
||||
@Output() export = new EventEmitter<void>();
|
||||
@ -77,21 +77,7 @@ export class TransactionsTableComponent
|
||||
private allFilters: string[];
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private dialog: MatDialog,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router
|
||||
) {
|
||||
this.routeQueryParams = route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['positionDetailDialog'] && params['symbol']) {
|
||||
this.openPositionDialog({
|
||||
symbol: params['symbol']
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
public constructor(private router: Router) {
|
||||
this.searchControl.valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((keyword) => {
|
||||
@ -150,6 +136,7 @@ export class TransactionsTableComponent
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'fee',
|
||||
'value',
|
||||
'account'
|
||||
];
|
||||
|
||||
@ -157,6 +144,12 @@ export class TransactionsTableComponent
|
||||
this.displayedColumns.push('actions');
|
||||
}
|
||||
|
||||
if (!this.showSymbolColumn) {
|
||||
this.displayedColumns = this.displayedColumns.filter((column) => {
|
||||
return column !== 'symbol';
|
||||
});
|
||||
}
|
||||
|
||||
this.isLoading = true;
|
||||
|
||||
if (this.transactions) {
|
||||
@ -210,27 +203,6 @@ export class TransactionsTableComponent
|
||||
this.transactionToClone.emit(aTransaction);
|
||||
}
|
||||
|
||||
public openPositionDialog({ symbol }: { symbol: string }): void {
|
||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
symbol,
|
||||
baseCurrency: this.baseCurrency,
|
||||
deviceType: this.deviceType,
|
||||
locale: this.locale
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -14,7 +14,6 @@ import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info'
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
|
||||
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
|
||||
import { TransactionsTableComponent } from './transactions-table.component';
|
||||
|
||||
@ -24,7 +23,6 @@ import { TransactionsTableComponent } from './transactions-table.component';
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfNoTransactionsInfoModule,
|
||||
GfPositionDetailDialogModule,
|
||||
GfSymbolIconModule,
|
||||
GfSymbolModule,
|
||||
GfValueModule,
|
||||
|
@ -4,8 +4,7 @@ import {
|
||||
HttpEvent,
|
||||
HttpHandler,
|
||||
HttpInterceptor,
|
||||
HttpRequest,
|
||||
HttpResponse
|
||||
HttpRequest
|
||||
} from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
@ -43,26 +42,6 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
||||
): Observable<HttpEvent<any>> {
|
||||
return next.handle(request).pipe(
|
||||
tap((event: HttpEvent<any>) => {
|
||||
if (event instanceof HttpResponse) {
|
||||
if (event.status === StatusCodes.ACCEPTED) {
|
||||
if (!this.snackBarRef) {
|
||||
this.snackBarRef = this.snackBar.open(
|
||||
'Sorry! Our data provider partner is experiencing a mild case of the hiccups ;(',
|
||||
'Try again?',
|
||||
{ duration: 6000 }
|
||||
);
|
||||
|
||||
this.snackBarRef.afterDismissed().subscribe(() => {
|
||||
this.snackBarRef = undefined;
|
||||
});
|
||||
|
||||
this.snackBarRef.onAction().subscribe(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return event;
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
|
@ -23,7 +23,7 @@
|
||||
This instance is running Ghostfolio {{ version }} and has been
|
||||
last published on {{ lastPublish }}.
|
||||
</ng-container>
|
||||
<ng-container *ngIf="hasPermissionForSubscription" i18n
|
||||
<ng-container *ngIf="hasPermissionForStatistics" i18n
|
||||
>Check the system status at
|
||||
<a href="https://status.ghostfol.io" title="Ghostfolio status"
|
||||
>status.ghostfol.io</a
|
||||
@ -35,8 +35,8 @@
|
||||
new feature, please join the Ghostfolio
|
||||
<a
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
title="Join the Ghostfolio Slack channel"
|
||||
>Slack channel</a
|
||||
title="Join the Ghostfolio Slack community"
|
||||
>Slack community</a
|
||||
>, tweet to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
@ -108,12 +108,7 @@
|
||||
<mat-card-content>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3
|
||||
class="mb-0"
|
||||
[hidden]="statistics?.activeUsers1d === undefined"
|
||||
>
|
||||
{{ statistics?.activeUsers1d || '-' }}
|
||||
</h3>
|
||||
<h3 class="mb-0">{{ statistics?.activeUsers1d || '-' }}</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>Active Users</span> <small class="text-muted"
|
||||
>(Last 24 hours)</small
|
||||
@ -121,35 +116,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3
|
||||
class="mb-0"
|
||||
[hidden]="statistics?.activeUsers7d === undefined"
|
||||
>
|
||||
{{ statistics?.activeUsers7d ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>Active Users</span> <small class="text-muted"
|
||||
>(Last 7 days)</small
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3
|
||||
class="mb-0"
|
||||
[hidden]="statistics?.activeUsers30d === undefined"
|
||||
>
|
||||
{{ statistics?.activeUsers30d ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>Active Users</span> <small class="text-muted"
|
||||
>(Last 30 days)</small
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0" [hidden]="statistics?.newUsers30d === undefined">
|
||||
{{ statistics?.newUsers30d ?? '-' }}
|
||||
</h3>
|
||||
<h3 class="mb-0">{{ statistics?.newUsers30d ?? '-' }}</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>New Users</span> <small class="text-muted"
|
||||
>(Last 30 days)</small
|
||||
@ -157,21 +124,23 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3
|
||||
class="mb-0"
|
||||
[hidden]="statistics?.gitHubContributors === undefined"
|
||||
>
|
||||
{{ statistics?.gitHubContributors ?? '-' }}
|
||||
</h3>
|
||||
<h3 class="mb-0">{{ statistics?.activeUsers30d ?? '-' }}</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>Active Users</span> <small class="text-muted"
|
||||
>(Last 30 days)</small
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0">{{ statistics?.slackCommunityUsers ?? '-' }}</h3>
|
||||
<div class="h6 mb-0" i18n>Users in Slack community</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0">{{ statistics?.gitHubContributors ?? '-' }}</h3>
|
||||
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3
|
||||
class="mb-0"
|
||||
[hidden]="statistics?.gitHubStargazers === undefined"
|
||||
>
|
||||
{{ statistics?.gitHubStargazers ?? '-' }}
|
||||
</h3>
|
||||
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
|
||||
<div class="h6 mb-0" i18n>Stars on GitHub</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
@ -10,6 +9,7 @@ import {
|
||||
PortfolioPosition,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { ToggleOption } from '@ghostfolio/common/types';
|
||||
import { AssetClass } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import { ToggleOption } from '@ghostfolio/common/types';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
@ -52,8 +52,9 @@
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Type</mat-label>
|
||||
<mat-select name="type" required [(value)]="data.transaction.type">
|
||||
<mat-option value="BUY" i18n> BUY </mat-option>
|
||||
<mat-option value="SELL" i18n> SELL </mat-option>
|
||||
<mat-option value="BUY" i18n>BUY</mat-option>
|
||||
<mat-option value="DIVIDEND" i18n>DIVIDEND</mat-option>
|
||||
<mat-option value="SELL" i18n>SELL</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@ -131,7 +132,7 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="unit-price w-100">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Unit Price</mat-label>
|
||||
<input
|
||||
matInput
|
||||
@ -141,7 +142,7 @@
|
||||
[(ngModel)]="data.transaction.unitPrice"
|
||||
/>
|
||||
<button
|
||||
*ngIf="currentMarketPrice"
|
||||
*ngIf="currentMarketPrice && (data.transaction.type === 'BUY' || data.transaction.type === 'SELL')"
|
||||
mat-icon-button
|
||||
matSuffix
|
||||
title="Apply current market price"
|
||||
|
@ -4,6 +4,7 @@ import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
|
||||
@ -73,6 +74,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
} else {
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
}
|
||||
} else if (params['positionDetailDialog'] && params['symbol']) {
|
||||
this.openPositionDialog({
|
||||
symbol: params['symbol']
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -188,7 +193,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
const fileContent = readerEvent.target.result as string;
|
||||
|
||||
try {
|
||||
if (file.type === 'application/json') {
|
||||
if (file.name.endsWith('.json')) {
|
||||
const content = JSON.parse(fileContent);
|
||||
|
||||
if (!isArray(content.orders)) {
|
||||
@ -203,11 +208,12 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.handleImportSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({ error, orders: content.orders });
|
||||
}
|
||||
|
||||
return;
|
||||
} else if (file.type === 'text/csv') {
|
||||
} else if (file.name.endsWith('.csv')) {
|
||||
try {
|
||||
await this.importTransactionsService.importCsv({
|
||||
fileContent,
|
||||
@ -217,6 +223,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.handleImportSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({
|
||||
error: {
|
||||
error: { message: error?.error?.message ?? [error?.message] }
|
||||
@ -230,6 +237,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
throw new Error();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({
|
||||
error: { error: { message: ['Unexpected format'] } },
|
||||
orders: []
|
||||
@ -247,6 +255,27 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public openPositionDialog({ symbol }: { symbol: string }): void {
|
||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||
autoFocus: false,
|
||||
data: {
|
||||
symbol,
|
||||
baseCurrency: this.user?.settings?.baseCurrency,
|
||||
deviceType: this.deviceType,
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
});
|
||||
}
|
||||
|
||||
public openUpdateTransactionDialog({
|
||||
accountId,
|
||||
currency,
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
@ -35,4 +37,40 @@ export class AdminService {
|
||||
|
||||
return this.http.post<MarketData | void>(url, {});
|
||||
}
|
||||
|
||||
public fetchSymbolForDate({
|
||||
dataSource,
|
||||
date,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}) {
|
||||
const url = `/api/symbol/${dataSource}/${symbol}/${format(
|
||||
date,
|
||||
DATE_FORMAT
|
||||
)}`;
|
||||
|
||||
return this.http.get<IDataProviderHistoricalResponse>(url);
|
||||
}
|
||||
|
||||
public putMarketData({
|
||||
dataSource,
|
||||
date,
|
||||
marketData,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
marketData: UpdateMarketDataDto;
|
||||
symbol: string;
|
||||
}) {
|
||||
const url = `/api/admin/market-data/${dataSource}/${symbol}/${format(
|
||||
date,
|
||||
DATE_FORMAT
|
||||
)}`;
|
||||
|
||||
return this.http.put<MarketData>(url, marketData);
|
||||
}
|
||||
}
|
||||
|
@ -181,7 +181,10 @@ export class DataService {
|
||||
}
|
||||
|
||||
public fetchPortfolioPerformance(aParams: { [param: string]: any }) {
|
||||
return this.http.get<PortfolioPerformance>('/api/portfolio/performance', {
|
||||
return this.http.get<{
|
||||
hasErrors: boolean;
|
||||
performance: PortfolioPerformance;
|
||||
}>('/api/portfolio/performance', {
|
||||
params: aParams
|
||||
});
|
||||
}
|
||||
@ -209,8 +212,17 @@ export class DataService {
|
||||
}
|
||||
|
||||
public fetchPositionDetail(aSymbol: string) {
|
||||
return this.http.get<PortfolioPositionDetail>(
|
||||
`/api/portfolio/position/${aSymbol}`
|
||||
return this.http.get<any>(`/api/portfolio/position/${aSymbol}`).pipe(
|
||||
map((data) => {
|
||||
if (data.orders) {
|
||||
for (const order of data.orders) {
|
||||
order.createdAt = parseISO(order.createdAt);
|
||||
order.date = parseISO(order.date);
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -15,8 +15,8 @@ export class ImportTransactionsService {
|
||||
private static CURRENCY_KEYS = ['ccy', 'currency'];
|
||||
private static DATE_KEYS = ['date'];
|
||||
private static FEE_KEYS = ['commission', 'fee'];
|
||||
private static QUANTITY_KEYS = ['qty', 'quantity', 'shares'];
|
||||
private static SYMBOL_KEYS = ['code', 'symbol'];
|
||||
private static QUANTITY_KEYS = ['qty', 'quantity', 'shares', 'units'];
|
||||
private static SYMBOL_KEYS = ['code', 'symbol', 'ticker'];
|
||||
private static TYPE_KEYS = ['action', 'type'];
|
||||
private static UNIT_PRICE_KEYS = ['price', 'unitprice', 'value'];
|
||||
|
||||
@ -214,10 +214,15 @@ export class ImportTransactionsService {
|
||||
|
||||
for (const key of ImportTransactionsService.TYPE_KEYS) {
|
||||
if (item[key]) {
|
||||
if (item[key].toLowerCase() === 'buy') {
|
||||
return Type.BUY;
|
||||
} else if (item[key].toLowerCase() === 'sell') {
|
||||
return Type.SELL;
|
||||
switch (item[key].toLowerCase()) {
|
||||
case 'buy':
|
||||
return Type.BUY;
|
||||
case 'dividend':
|
||||
return Type.DIVIDEND;
|
||||
case 'sell':
|
||||
return Type.SELL;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,15 @@
|
||||
version: '3.7'
|
||||
services:
|
||||
ghostfolio:
|
||||
build: ../
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer
|
||||
REDIS_HOST: 'redis'
|
||||
ports:
|
||||
- 3333:3333
|
||||
|
||||
postgres:
|
||||
image: postgres:12
|
||||
env_file:
|
||||
@ -10,15 +20,5 @@ services:
|
||||
redis:
|
||||
image: 'redis:alpine'
|
||||
|
||||
ghostfolio:
|
||||
build: ../
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
REDIS_HOST: 'redis'
|
||||
DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer
|
||||
ports:
|
||||
- 3333:3333
|
||||
|
||||
volumes:
|
||||
postgres:
|
22
docker/docker-compose.dev.yml
Normal file
22
docker/docker-compose.dev.yml
Normal file
@ -0,0 +1,22 @@
|
||||
version: '3.7'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:12
|
||||
container_name: postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 5432:5432
|
||||
env_file:
|
||||
- ../.env
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
|
||||
redis:
|
||||
image: 'redis:alpine'
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
volumes:
|
||||
postgres:
|
@ -1,11 +1,17 @@
|
||||
version: '3.7'
|
||||
services:
|
||||
ghostfolio:
|
||||
image: ghostfolio/ghostfolio
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer
|
||||
REDIS_HOST: 'redis'
|
||||
ports:
|
||||
- 3333:3333
|
||||
|
||||
postgres:
|
||||
image: postgres:12
|
||||
container_name: postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 5432:5432
|
||||
env_file:
|
||||
- ../.env
|
||||
volumes:
|
||||
@ -13,10 +19,6 @@ services:
|
||||
|
||||
redis:
|
||||
image: 'redis:alpine'
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- 6379:6379
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
|
@ -1,5 +1,15 @@
|
||||
import { ToggleOption } from './types';
|
||||
|
||||
export const baseCurrency = 'USD';
|
||||
|
||||
export const defaultDateRangeOptions: ToggleOption[] = [
|
||||
{ label: 'Today', value: '1d' },
|
||||
{ label: 'YTD', value: 'ytd' },
|
||||
{ label: '1Y', value: '1y' },
|
||||
{ label: '5Y', value: '5y' },
|
||||
{ label: 'Max', value: 'max' }
|
||||
];
|
||||
|
||||
export const ghostfolioScraperApiSymbolPrefix = '_GF_';
|
||||
export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`;
|
||||
export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`;
|
||||
@ -35,6 +45,7 @@ export const PROPERTY_CURRENCIES = 'CURRENCIES';
|
||||
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';
|
||||
export const PROPERTY_LAST_DATA_GATHERING = 'LAST_DATA_GATHERING';
|
||||
export const PROPERTY_LOCKED_DATA_GATHERING = 'LOCKED_DATA_GATHERING';
|
||||
export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS';
|
||||
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
|
||||
export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE';
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
|
||||
|
||||
export interface PortfolioChart {
|
||||
hasError: boolean;
|
||||
isAllTimeHigh: boolean;
|
||||
isAllTimeLow: boolean;
|
||||
chart: HistoricalDataItem[];
|
||||
|
@ -3,6 +3,7 @@ import { PortfolioPerformance } from './portfolio-performance.interface';
|
||||
export interface PortfolioSummary extends PortfolioPerformance {
|
||||
annualizedPerformancePercent: number;
|
||||
cash: number;
|
||||
dividend: number;
|
||||
committedFunds: number;
|
||||
fees: number;
|
||||
firstOrderDate: Date;
|
||||
|
@ -1,8 +1,8 @@
|
||||
export interface Statistics {
|
||||
activeUsers1d: number;
|
||||
activeUsers7d: number;
|
||||
activeUsers30d: number;
|
||||
gitHubContributors: number;
|
||||
gitHubStargazers: number;
|
||||
newUsers30d: number;
|
||||
slackCommunityUsers: string;
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import type { DateRange } from './date-range.type';
|
||||
import type { Granularity } from './granularity.type';
|
||||
import type { OrderWithAccount } from './order-with-account.type';
|
||||
import type { RequestWithUser } from './request-with-user.type';
|
||||
import { ToggleOption } from './toggle-option.type';
|
||||
|
||||
export type {
|
||||
AccessWithGranteeUser,
|
||||
@ -11,5 +12,6 @@ export type {
|
||||
DateRange,
|
||||
Granularity,
|
||||
OrderWithAccount,
|
||||
RequestWithUser
|
||||
RequestWithUser,
|
||||
ToggleOption
|
||||
};
|
||||
|
16
package.json
16
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.91.0",
|
||||
"version": "1.98.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -69,7 +69,7 @@
|
||||
"@nestjs/schedule": "1.0.2",
|
||||
"@nestjs/serve-static": "2.2.2",
|
||||
"@nrwl/angular": "13.3.0",
|
||||
"@prisma/client": "3.6.0",
|
||||
"@prisma/client": "3.7.0",
|
||||
"@simplewebauthn/browser": "4.1.0",
|
||||
"@simplewebauthn/server": "4.1.0",
|
||||
"@simplewebauthn/typescript-types": "4.0.0",
|
||||
@ -94,18 +94,18 @@
|
||||
"cryptocurrencies": "7.0.0",
|
||||
"date-fns": "2.22.1",
|
||||
"envalid": "7.2.1",
|
||||
"http-status-codes": "2.1.4",
|
||||
"http-status-codes": "2.2.0",
|
||||
"ionicons": "5.5.1",
|
||||
"lodash": "4.17.21",
|
||||
"ngx-device-detector": "2.1.1",
|
||||
"ngx-markdown": "12.0.1",
|
||||
"ngx-device-detector": "3.0.0",
|
||||
"ngx-markdown": "13.0.0",
|
||||
"ngx-skeleton-loader": "2.9.1",
|
||||
"ngx-stripe": "12.0.2",
|
||||
"ngx-stripe": "13.0.0",
|
||||
"papaparse": "5.3.1",
|
||||
"passport": "0.4.1",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.0",
|
||||
"prisma": "3.6.0",
|
||||
"prisma": "3.7.0",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"round-to": "5.0.0",
|
||||
"rxjs": "7.4.0",
|
||||
@ -161,7 +161,7 @@
|
||||
"import-sort-style-module": "6.0.0",
|
||||
"jest": "27.2.3",
|
||||
"jest-preset-angular": "11.0.0",
|
||||
"prettier": "2.3.2",
|
||||
"prettier": "2.5.1",
|
||||
"replace-in-file": "6.2.0",
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "27.0.5",
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "Type" ADD VALUE 'DIVIDEND';
|
@ -1,6 +1,3 @@
|
||||
// This is your Prisma schema file,
|
||||
// learn more about it in the docs: https://pris.ly/d/prisma-schema
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
@ -209,5 +206,6 @@ enum Role {
|
||||
|
||||
enum Type {
|
||||
BUY
|
||||
DIVIDEND
|
||||
SELL
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
set -xe
|
||||
echo "$DOCKER_HUB_ACCESS_TOKEN" | docker login -u "$DOCKER_HUB_USERNAME" --password-stdin
|
||||
|
||||
docker build -t ghostfolio/ghostfolio:$TRAVIS_TAG .
|
||||
docker push ghostfolio/ghostfolio:$TRAVIS_TAG
|
||||
docker build -t ghostfolio/ghostfolio:$TRAVIS_TAG -t ghostfolio/ghostfolio:latest .
|
||||
docker push ghostfolio/ghostfolio --all-tags
|
||||
|
@ -1,2 +1,3 @@
|
||||
Date,Code,Currency,Price,Quantity,Action,Fee
|
||||
17/11/2021,MSFT,USD,0.62,5,dividend,0.00
|
||||
16/09/2021,MSFT,USD,298.580,5,buy,19.00
|
||||
|
|
112
yarn.lock
112
yarn.lock
@ -2854,22 +2854,22 @@
|
||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be"
|
||||
integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw==
|
||||
|
||||
"@prisma/client@3.6.0":
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.6.0.tgz#68a60cd4c73a369b11f72e173e86fd6789939293"
|
||||
integrity sha512-ycSGY9EZGROtje0iCNsgC5Zqi/ttX2sO7BNMYaLsUMiTlf3F69ZPH+08pRo0hrDfkZzyimXYqeXJlaoYDH1w7A==
|
||||
"@prisma/client@3.7.0":
|
||||
version "3.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.7.0.tgz#9cafc105f12635c95e9b7e7b18e8fbf52cf3f18a"
|
||||
integrity sha512-fUJMvBOX5C7JPc0e3CJD6Gbelbu4dMJB4ScYpiht8HMUnRShw20ULOipTopjNtl6ekHQJ4muI7pXlQxWS9nMbw==
|
||||
dependencies:
|
||||
"@prisma/engines-version" "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
|
||||
"@prisma/engines-version" "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f"
|
||||
|
||||
"@prisma/engines-version@3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727":
|
||||
version "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727.tgz#25aa447776849a774885866b998732b37ec4f4f5"
|
||||
integrity sha512-vtoO2ys6mSfc8ONTWdcYztKN3GBU1tcKBj0aXObyjzSuGwHFcM/pEA0xF+n1W4/0TAJgfoPX2khNEit6g0jtNA==
|
||||
"@prisma/engines-version@3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f":
|
||||
version "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f.tgz#055f36ac8b06c301332c14963cd0d6c795942c90"
|
||||
integrity sha512-+qx2b+HK7BKF4VCa0LZ/t1QCXsu6SmvhUQyJkOD2aPpmOzket4fEnSKQZSB0i5tl7rwCDsvAiSeK8o7rf+yvwg==
|
||||
|
||||
"@prisma/engines@3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727":
|
||||
version "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727.tgz#c68ede6aeffa9ef7743a32cfa6daf9172a4e15b3"
|
||||
integrity sha512-dRClHS7DsTVchDKzeG72OaEyeDskCv91pnZ72Fftn0mp4BkUvX2LvWup65hCNzwwQm5IDd6A88APldKDnMiEMA==
|
||||
"@prisma/engines@3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f":
|
||||
version "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f.tgz#12f28d5b78519fbd84c89a5bdff457ff5095e7a2"
|
||||
integrity sha512-W549ub5NlgexNhR8EFstA/UwAWq3Zq0w9aNkraqsozVCt2CsX+lK4TK7IW5OZVSnxHwRjrgEAt3r9yPy8nZQRg==
|
||||
|
||||
"@samverschueren/stream-to-observable@^0.3.0":
|
||||
version "0.3.1"
|
||||
@ -6688,11 +6688,16 @@ commander@^5.1.0:
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae"
|
||||
integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==
|
||||
|
||||
commander@^6.0.0, commander@^6.2.1:
|
||||
commander@^6.2.1:
|
||||
version "6.2.1"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
|
||||
integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
|
||||
|
||||
commander@^8.0.0:
|
||||
version "8.3.0"
|
||||
resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
|
||||
integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==
|
||||
|
||||
common-tags@^1.8.0:
|
||||
version "1.8.0"
|
||||
resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937"
|
||||
@ -7799,7 +7804,7 @@ emoji-regex@^8.0.0:
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
|
||||
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
|
||||
|
||||
emoji-toolkit@^6.5.0:
|
||||
emoji-toolkit@^6.6.0:
|
||||
version "6.6.0"
|
||||
resolved "https://registry.yarnpkg.com/emoji-toolkit/-/emoji-toolkit-6.6.0.tgz#e7287c43a96f940ec4c5428cd7100a40e57518f1"
|
||||
integrity sha512-pEu0kow2p1N8zCKnn/L6H0F3rWUBB3P3hVjr/O5yl1fK7N9jU4vO4G7EFapC5Y3XwZLUCY0FZbOPyTkH+4V2eQ==
|
||||
@ -10027,10 +10032,10 @@ http-signature@~1.2.0:
|
||||
jsprim "^1.2.2"
|
||||
sshpk "^1.7.0"
|
||||
|
||||
http-status-codes@2.1.4:
|
||||
version "2.1.4"
|
||||
resolved "https://registry.yarnpkg.com/http-status-codes/-/http-status-codes-2.1.4.tgz#453d99b4bd9424254c4f6a9a3a03715923052798"
|
||||
integrity sha512-MZVIsLKGVOVE1KEnldppe6Ij+vmemMuApDfjhVSLzyYP+td0bREEYyAoIw9yFePoBXManCuBqmiNP5FqJS5Xkg==
|
||||
http-status-codes@2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/http-status-codes/-/http-status-codes-2.2.0.tgz#bb2efe63d941dfc2be18e15f703da525169622be"
|
||||
integrity sha512-feERVo9iWxvnejp3SEfm/+oNG517npqL2/PIA8ORjyOZjGC7TwCRQsZylciLS64i6pJ0wRYz3rkXLRwbtFa8Ng==
|
||||
|
||||
https-browserify@^1.0.0:
|
||||
version "1.0.0"
|
||||
@ -11859,12 +11864,12 @@ karma-source-map-support@1.4.0:
|
||||
dependencies:
|
||||
source-map-support "^0.5.5"
|
||||
|
||||
katex@^0.13.0:
|
||||
version "0.13.18"
|
||||
resolved "https://registry.yarnpkg.com/katex/-/katex-0.13.18.tgz#ba89e8e4b70cc2325e25e019a62b9fe71e5c2931"
|
||||
integrity sha512-a3dC4NSVSDU3O1WZbTnOiA8rVNJ2lSiomOl0kmckCIGObccIHXof7gAseIY0o1gjEspe+34ZeSEX2D1ChFKIvA==
|
||||
katex@^0.15.1:
|
||||
version "0.15.1"
|
||||
resolved "https://registry.yarnpkg.com/katex/-/katex-0.15.1.tgz#cf4ce2fa1257c3279cc7a7fe0c8d1fab40800893"
|
||||
integrity sha512-KIk+gizli0gl1XaJlCYS8/donGMbzXYTka6BbH3AgvDJTOwyDY4hJ+YmzJ1F0y/3XzX5B9ED8AqB2Hmn2AZ0uA==
|
||||
dependencies:
|
||||
commander "^6.0.0"
|
||||
commander "^8.0.0"
|
||||
|
||||
kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0:
|
||||
version "3.2.2"
|
||||
@ -12821,24 +12826,24 @@ nested-error-stacks@^2.0.0, nested-error-stacks@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.1.0.tgz#0fbdcf3e13fe4994781280524f8b96b0cdff9c61"
|
||||
integrity sha512-AO81vsIO1k1sM4Zrd6Hu7regmJN1NSiAja10gc4bX3F0wd+9rQmcuHQaHVQCYIEC8iFXnE+mavh23GOt7wBgug==
|
||||
|
||||
ngx-device-detector@2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/ngx-device-detector/-/ngx-device-detector-2.1.1.tgz#a22a9477f382d02edf28786c5609878a57d2834f"
|
||||
integrity sha512-eTuQLAmc2XRRbxDnO9h1QVV0piSyPjstXT5G8fo1rvXy7Ly3MAiniEM2WvTiN7FjtY/VdhEeuBmu/ErSm5cLJg==
|
||||
ngx-device-detector@3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ngx-device-detector/-/ngx-device-detector-3.0.0.tgz#9c5b1db66e03837d5de0e93fe4a1de93948c9c81"
|
||||
integrity sha512-mzegvxnNTDkHTxh+UeWnCUgZ91/XDOcN2kj8aCupvA7wNgDc/NZ0L90feKJsc+wES7IWq0/DIIKq2F732WOkfw==
|
||||
dependencies:
|
||||
tslib "^2.0.0"
|
||||
|
||||
ngx-markdown@12.0.1:
|
||||
version "12.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ngx-markdown/-/ngx-markdown-12.0.1.tgz#94345e99176533c17396f93e97ff5dc172d8ebcc"
|
||||
integrity sha512-vMp9SyqmVQZCX374MiCV4sRR1SIv5m3xR2HZ39b3+6/BGjAb46mb4wRXKdIxYUoPba7NYZ8GAt5moUCyVZcCyA==
|
||||
ngx-markdown@13.0.0:
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ngx-markdown/-/ngx-markdown-13.0.0.tgz#07c9ef46db6827290fc533c0ee64d3856e964bfd"
|
||||
integrity sha512-XIFCoqffGUHoc8mpHphVskFBHck6hUBocyGVHNBznk7dzHdy6+Ir08jECDQa6xhsoU4dTDgo9aofjK+yvzGIXw==
|
||||
dependencies:
|
||||
"@types/marked" "^2.0.0"
|
||||
emoji-toolkit "^6.5.0"
|
||||
katex "^0.13.0"
|
||||
emoji-toolkit "^6.6.0"
|
||||
katex "^0.15.1"
|
||||
marked "^2.0.0"
|
||||
prismjs "^1.23.0"
|
||||
tslib "^2.1.0"
|
||||
prismjs "^1.25.0"
|
||||
tslib "^2.3.0"
|
||||
|
||||
ngx-skeleton-loader@2.9.1:
|
||||
version "2.9.1"
|
||||
@ -12848,12 +12853,12 @@ ngx-skeleton-loader@2.9.1:
|
||||
perf-marks "^1.13.4"
|
||||
tslib "^1.10.0"
|
||||
|
||||
ngx-stripe@12.0.2:
|
||||
version "12.0.2"
|
||||
resolved "https://registry.yarnpkg.com/ngx-stripe/-/ngx-stripe-12.0.2.tgz#b250acc2a08dc96dac035fc0a67b4a8cbeca3efb"
|
||||
integrity sha512-/arfIi996yv3EpzqjYsb20TUdQ9t+GVMNVIx1mdsiWcpiNjL36tO3lG45T0hyiBJNAds87Ag40Fm8PfsuHFCUw==
|
||||
ngx-stripe@13.0.0:
|
||||
version "13.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ngx-stripe/-/ngx-stripe-13.0.0.tgz#d5ed50590447aa74012de4e75ac9bcdafc68b1c8"
|
||||
integrity sha512-SImKvoC/mZZrtzh2UUmxFdkqMLKX2y+BtcvMAPdHD4D7miXWEjCTZeXt8h85mcfy7y1NKKwIipH4CSr9eBzZ4w==
|
||||
dependencies:
|
||||
tslib "^2.1.0"
|
||||
tslib "^2.3.0"
|
||||
|
||||
nice-napi@^1.0.2:
|
||||
version "1.0.2"
|
||||
@ -14380,10 +14385,10 @@ prelude-ls@~1.1.2:
|
||||
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54"
|
||||
integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=
|
||||
|
||||
prettier@2.3.2:
|
||||
version "2.3.2"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.2.tgz#ef280a05ec253712e486233db5c6f23441e7342d"
|
||||
integrity sha512-lnJzDfJ66zkMy58OL5/NY5zp70S7Nz6KqcKkXYzn2tMVrNxvbqaBpg7H3qHaLxCJ5lNMsGuM8+ohS7cZrthdLQ==
|
||||
prettier@2.5.1:
|
||||
version "2.5.1"
|
||||
resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.5.1.tgz#fff75fa9d519c54cf0fce328c1017d94546bc56a"
|
||||
integrity sha512-vBZcPRUR5MZJwoyi3ZoyQlc1rXeEck8KgeC9AwwOn+exuxLxq5toTRDTSaVrXHxelDMHy9zlicw8u66yxoSUFg==
|
||||
|
||||
prettier@^2.2.1:
|
||||
version "2.4.1"
|
||||
@ -14436,18 +14441,23 @@ pretty-hrtime@^1.0.3:
|
||||
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
|
||||
integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=
|
||||
|
||||
prisma@3.6.0:
|
||||
version "3.6.0"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.6.0.tgz#99532abc02e045e58c6133a19771bdeb28cecdbe"
|
||||
integrity sha512-6SqgHS/5Rq6HtHjsWsTxlj+ySamGyCLBUQfotc2lStOjPv52IQuDVpp58GieNqc9VnfuFyHUvTZw7aQB+G2fvQ==
|
||||
prisma@3.7.0:
|
||||
version "3.7.0"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.7.0.tgz#9c73eeb2f16f767fdf523d0f4cc4c749734d62e2"
|
||||
integrity sha512-pzgc95msPLcCHqOli7Hnabu/GRfSGSUWl5s2P6N13T/rgMB+NNeKbxCmzQiZT2yLOeLEPivV6YrW1oeQIwJxcg==
|
||||
dependencies:
|
||||
"@prisma/engines" "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
|
||||
"@prisma/engines" "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f"
|
||||
|
||||
prismjs@^1.21.0, prismjs@^1.23.0, prismjs@~1.24.0:
|
||||
prismjs@^1.21.0, prismjs@~1.24.0:
|
||||
version "1.24.1"
|
||||
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.24.1.tgz#c4d7895c4d6500289482fa8936d9cdd192684036"
|
||||
integrity sha512-mNPsedLuk90RVJioIky8ANZEwYm5w9LcvCXrxHlwf4fNVSn8jEipMybMkWUyyF0JhnC+C4VcOVSBuHRKs1L5Ow==
|
||||
|
||||
prismjs@^1.25.0:
|
||||
version "1.25.0"
|
||||
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.25.0.tgz#6f822df1bdad965734b310b315a23315cf999756"
|
||||
integrity sha512-WCjJHl1KEWbnkQom1+SzftbtXMKQoezOCYs5rECqMN+jP+apI7ftoflyqigqzopSO3hMhTEb0mFClA8lkolgEg==
|
||||
|
||||
process-nextick-args@~2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
|
||||
|
Reference in New Issue
Block a user