Compare commits

..

36 Commits

Author SHA1 Message Date
3330ae70b6 Release 1.37.0 (#288) 2021-08-13 20:49:05 +02:00
96a615dc5d Bugfix/only allow supported currencies in symbol search (#287)
* Only allow supported currencies in symbol search

* Update changelog
2021-08-13 20:29:57 +02:00
98f44323da Feature/improve usability of tabs on home page (#283)
* Improve usability: lazy load endpoints on tab change

* Feature/improve portfolio summary (#285)

* Update changelog
2021-08-13 19:26:48 +02:00
8adacd9760 Feature/upgrade angular material css vars to version 2.1.2 (#282)
* Upgrade angular-material-css-vars from version 2.1.0 to 2.1.2

* Update changelog
2021-08-13 19:05:22 +02:00
908aba170d Fix position chart for missing historical data (#284)
* Fix position chart for missing historical data

* Update changelog
2021-08-12 23:30:04 +02:00
d599797a65 Release 1.36.0 (#281) 2021-08-09 22:06:19 +02:00
8ac1272a9d Feature/eliminate name from scraper config (#277)
* Eliminate name from scraper config

* Update changelog
2021-08-09 21:33:58 +02:00
0a85a56c67 Respect cash balance in allocations, do not hide cryptocurrency holdings (#280)
* Respect cash balance in allocations, do not hide cryptocurrency holdings

* Update changelog
2021-08-09 21:26:41 +02:00
4ad5590838 Feature/improve data gathering (#276)
* Improve data gathering
  * Refactoring
  * On server restart, only reset if hanging in LOCKED_DATA_GATHERING state

* Update changelog
2021-08-09 21:11:35 +02:00
5b8af68e71 Release 1.35.0 (#279) 2021-08-08 21:23:48 +02:00
80d043729d Feature/replace type with asset class (#274)
* Improved the asset classification
  * Add assetClass to symbolProfile
  * Remove type from position

* Update changelog
2021-08-08 19:27:58 +02:00
178166d86b Extend investment chart by three months (#273) 2021-08-08 19:25:38 +02:00
37358fb480 Bugfix/add fallback if exchange service is not initialized (#264)
* Add fallback and log error

* Update changelog
2021-08-08 19:24:51 +02:00
616d601cf6 Improve wording (#275) 2021-08-08 12:20:30 +02:00
e88b889fdd Feature/optimize accounts table for mobile (#271)
* Optimize accounts table

* Update changelog
2021-08-08 09:24:47 +02:00
f6cdc4ff47 Feature/disable pagination of tabs (#272)
* Disable pagination

* Update changelog
2021-08-08 09:24:30 +02:00
818c40fc61 Feature/upgrade chart.js to version 3.5.0 (#268)
* Upgrade chart.js

* Update changelog
2021-08-08 08:33:55 +02:00
3589e72aea Harmonize prisma service (#266) 2021-08-07 22:38:07 +02:00
e68aa1fa68 Clean up imports (#267) 2021-08-07 22:37:39 +02:00
bb76ace95d Feature/improve support for draft transactions (#265)
* Improve support for draft transactions

* Update changelog
2021-08-07 20:52:55 +02:00
2bd9309827 Release 1.34.0 (#263) 2021-08-07 07:28:16 +02:00
f743c03e17 Feature/restructure page hierarchy (#259)
* Introduce portfolio group, remove tools

* Extract allocations to separate page

* Update changelog
2021-08-07 07:12:40 +02:00
dfcf826b4f implement support for buy-sell(-buy) scenario (#262)
Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-08-07 07:07:12 +02:00
218efbb5bd fix position diagram for sold items (#261)
* fix position diagram for sold items

* Add missing data to position from the past

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-08-07 07:03:35 +02:00
ba3b4564cd Bugfix/dashboard currencies (#260)
* filter list of orders only for relevant orders

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-08-06 21:47:27 +02:00
3723a5c725 Release 1.33.0 (#257) 2021-08-05 22:54:12 +02:00
bf256ae50c fix position currency conversion (#256)
Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-08-05 22:36:18 +02:00
c4b6273886 Clean up viewport scroller (#255) 2021-08-05 22:32:55 +02:00
7f047362cc Bugfix/fix division by zero in calculate overall gross performance (#253)
* Fix error with division by zero

* Update changelog
2021-08-05 07:49:25 +02:00
66900ffa24 Release 1.32.0 (#254) 2021-08-04 21:42:50 +02:00
33e05e949a Add links (#252)
* Ghostfolio Premium
* Blog
* Twitter
2021-08-04 20:59:59 +02:00
94d2310217 Bugfix/fix spike of positions with differing currency of user (#248)
* Convert market price back to currency of position

* Update changelog
2021-08-04 20:43:00 +02:00
b7d950f3f9 Bugfix/fix missing market state in positions (#250)
* Fix missing market state in positions

* Update changelog
2021-08-04 17:55:34 +02:00
b7943889da Feature/add screenshot (#251)
* Add screenshot

* Update changelog
2021-08-03 21:02:51 +02:00
84aa542560 Extend changelog (#247) 2021-08-02 21:56:36 +02:00
4bd41ffa41 Feature/eliminate name from timeline position (#245)
* Eliminate name from timeline position: Get the name from the symbolProfileService instead

* Update changelog
2021-08-01 20:43:12 +02:00
119 changed files with 1942 additions and 1348 deletions

View File

@ -5,6 +5,85 @@ 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.37.0 - 13.08.2021
### Added
- Added the calculated net worth to the portfolio summary tab on the home page
- Added the calculated time in market to the portfolio summary tab on the home page
### Changed
- Improved the usability of the tabs on the home page
- Restructured the portfolio summary tab on the home page
- Upgraded `angular-material-css-vars` from version `2.1.0` to `2.1.2`
### Fixed
- Fixed the position detail chart if there are missing historical data around the first buy date
- Fixed the snack bar background color in dark mode
- Fixed the search functionality for symbols (filter for supported currencies)
## 1.36.0 - 09.08.2021
### Changed
- Improved the data gathering handling on server restart
- Respected the cash balance on the allocations page
- Eliminated the name from the scraper configuration
### Fixed
- Fixed hidden cryptocurrency holdings
## 1.35.0 - 08.08.2021
### Changed
- Hid the pagination of tabs
- Improved the classification of assets
- Improved the support for future transactions (drafts)
- Optimized the accounts table for mobile
- Upgraded `chart.js` from version `3.3.2` to `3.5.0`
### Fixed
- Added a fallback if the exchange rate service has not been initialized correctly
### Todo
- Apply data migration (`yarn database:push`)
## 1.34.0 - 07.08.2021
### Changed
- Restructured the page hierarchy
### Fixed
- Fixed an issue with the currency conversion of the market price in the position detail dialog
- Fixed the chart and missing data of positions from the past in the position detail dialog
## 1.33.0 - 05.08.2021
### Fixed
- Fixed an issue of a division by zero in the portfolio calculations
- Fixed an issue with the currency conversion in the position detail dialog
## 1.32.0 - 04.08.2021
### Added
- Added the name to the position detail dialog when opened from the transactions table
- Added a screenshot to the blog posts
### Fixed
- Fixed the missing market state in the positions tab
- Fixed the chart of positions with differing currency from user
## 1.31.1 - 01.08.2021
### Fixed
@ -19,8 +98,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Refactored the core engine for the portfolio calculations
- Improved the performance of the portfolio calculations
- Rewritten the core engine for the portfolio calculations
- Switched to [Time-Weighted Rate of Return](https://www.investopedia.com/terms/t/time-weightedror.asp) (TWR) for the performance calculation
- Improved the performance of the portfolio calculations
## 1.30.0 - 31.07.2021

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p>
<p>
<a href="#contributing">
@ -26,6 +26,10 @@
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
<div align="center">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
</div>
## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.

View File

@ -5,7 +5,7 @@ import { Prisma } from '@prisma/client';
@Injectable()
export class AccessService {
public constructor(private prisma: PrismaService) {}
public constructor(private readonly prismaService: PrismaService) {}
public async accesses(params: {
include?: Prisma.AccessInclude;
@ -17,7 +17,7 @@ export class AccessService {
}): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.access.findMany({
return this.prismaService.access.findMany({
cursor,
include,
orderBy,

View File

@ -3,21 +3,19 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Account, Currency, Order, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CashDetails } from './interfaces/cash-details.interface';
@Injectable()
export class AccountService {
public constructor(
private exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
public async account(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
): Promise<Account | null> {
return this.prisma.account.findUnique({
return this.prismaService.account.findUnique({
where: accountWhereUniqueInput
});
}
@ -30,7 +28,7 @@ export class AccountService {
Order?: Order[];
}
> {
return this.prisma.account.findUnique({
return this.prismaService.account.findUnique({
include: accountInclude,
where: accountWhereUniqueInput
});
@ -46,7 +44,7 @@ export class AccountService {
}): Promise<Account[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.account.findMany({
return this.prismaService.account.findMany({
cursor,
include,
orderBy,
@ -60,7 +58,7 @@ export class AccountService {
data: Prisma.AccountCreateInput,
aUserId: string
): Promise<Account> {
return this.prisma.account.create({
return this.prismaService.account.create({
data
});
}
@ -69,9 +67,7 @@ export class AccountService {
where: Prisma.AccountWhereUniqueInput,
aUserId: string
): Promise<Account> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
return this.prisma.account.delete({
return this.prismaService.account.delete({
where
});
}
@ -105,7 +101,7 @@ export class AccountService {
aUserId: string
): Promise<Account> {
const { data, where } = params;
return this.prisma.account.update({
return this.prismaService.account.update({
data,
where
});

View File

@ -1,3 +1,4 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AdminData } from '@ghostfolio/common/interfaces';
@ -7,8 +8,9 @@ import { Currency } from '@prisma/client';
@Injectable()
export class AdminService {
public constructor(
private exchangeRateDataService: ExchangeRateDataService,
private prisma: PrismaService
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
public async get(): Promise<AdminData> {
@ -61,24 +63,22 @@ export class AdminService {
}
],
lastDataGathering: await this.getLastDataGathering(),
transactionCount: await this.prisma.order.count(),
userCount: await this.prisma.user.count(),
transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics()
};
}
private async getLastDataGathering() {
const lastDataGathering = await this.prisma.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' }
});
const lastDataGathering =
await this.dataGatheringService.getLastDataGathering();
if (lastDataGathering?.value) {
return new Date(lastDataGathering.value);
if (lastDataGathering) {
return lastDataGathering;
}
const dataGatheringInProgress = await this.prisma.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' }
});
const dataGatheringInProgress =
await this.dataGatheringService.getIsInProgress();
if (dataGatheringInProgress) {
return 'IN_PROGRESS';
@ -88,7 +88,7 @@ export class AdminService {
}
private async getUsersWithAnalytics() {
return await this.prisma.user.findMany({
return await this.prismaService.user.findMany({
orderBy: {
Analytics: {
updatedAt: 'desc'

View File

@ -1,12 +1,12 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Controller } from '@nestjs/common';
import { PrismaService } from '../services/prisma.service';
import { RedisCacheService } from './redis-cache/redis-cache.service';
@Controller()
export class AppController {
public constructor(
private prisma: PrismaService,
private readonly dataGatheringService: DataGatheringService,
private readonly redisCacheService: RedisCacheService
) {
this.initialize();
@ -15,17 +15,12 @@ export class AppController {
private async initialize() {
this.redisCacheService.reset();
const isDataGatheringLocked = await this.prisma.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' }
});
const isDataGatheringInProgress =
await this.dataGatheringService.getIsInProgress();
if (!isDataGatheringLocked) {
// Prepare for automatical data gather if not locked
await this.prisma.property.deleteMany({
where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
}
});
if (isDataGatheringInProgress) {
// Prepare for automatical data gathering, if hung up in progress state
await this.dataGatheringService.reset();
}
}
}

View File

@ -7,13 +7,13 @@ import { AuthDevice, Prisma } from '@prisma/client';
export class AuthDeviceService {
public constructor(
private readonly configurationService: ConfigurationService,
private prisma: PrismaService
private readonly prismaService: PrismaService
) {}
public async authDevice(
where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice | null> {
return this.prisma.authDevice.findUnique({
return this.prismaService.authDevice.findUnique({
where
});
}
@ -26,7 +26,7 @@ export class AuthDeviceService {
orderBy?: Prisma.AuthDeviceOrderByInput;
}): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.authDevice.findMany({
return this.prismaService.authDevice.findMany({
skip,
take,
cursor,
@ -38,7 +38,7 @@ export class AuthDeviceService {
public async createAuthDevice(
data: Prisma.AuthDeviceCreateInput
): Promise<AuthDevice> {
return this.prisma.authDevice.create({
return this.prismaService.authDevice.create({
data
});
}
@ -49,7 +49,7 @@ export class AuthDeviceService {
}): Promise<AuthDevice> {
const { data, where } = params;
return this.prisma.authDevice.update({
return this.prismaService.authDevice.update({
data,
where
});
@ -58,7 +58,7 @@ export class AuthDeviceService {
public async deleteAuthDevice(
where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice> {
return this.prisma.authDevice.delete({
return this.prismaService.authDevice.delete({
where
});
}

View File

@ -10,7 +10,7 @@ import { UserService } from '../user/user.service';
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
public constructor(
readonly configurationService: ConfigurationService,
private prisma: PrismaService,
private readonly prismaService: PrismaService,
private readonly userService: UserService
) {
super({
@ -24,7 +24,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
const user = await this.userService.user({ id });
if (user) {
await this.prisma.analytics.upsert({
await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } },
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
where: { userId: user.id }

View File

@ -21,6 +21,6 @@ export class CacheController {
public async flushCache(): Promise<void> {
this.redisCacheService.reset();
return this.cacheService.flush(this.request.user.id);
return this.cacheService.flush();
}
}

View File

@ -1,3 +1,10 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
@ -8,6 +15,16 @@ import { CacheService } from './cache.service';
@Module({
imports: [RedisCacheModule],
controllers: [CacheController],
providers: [CacheService, PrismaService]
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class CacheModule {}

View File

@ -1,16 +1,14 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class CacheService {
public constructor(private prisma: PrismaService) {}
public constructor(
private readonly dataGaterhingService: DataGatheringService
) {}
public async flush(aUserId: string): Promise<void> {
await this.prisma.property.deleteMany({
where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
}
});
public async flush(): Promise<void> {
await this.dataGaterhingService.reset();
return;
}

View File

@ -5,7 +5,6 @@ export interface TransactionPointSymbol {
currency: Currency;
firstBuyDate: string;
investment: Big;
name: string;
quantity: Big;
symbol: string;
transactionCount: number;

View File

@ -7,7 +7,7 @@ import { DateQuery } from './interfaces/date-query.interface';
@Injectable()
export class MarketDataService {
public constructor(private prisma: PrismaService) {}
public constructor(private readonly prismaService: PrismaService) {}
public async get({
date,
@ -16,7 +16,7 @@ export class MarketDataService {
date: Date;
symbol: string;
}): Promise<MarketData> {
return await this.prisma.marketData.findFirst({
return await this.prismaService.marketData.findFirst({
where: {
symbol,
date: resetHours(date)
@ -31,7 +31,7 @@ export class MarketDataService {
dateQuery: DateQuery;
symbols: string[];
}): Promise<MarketData[]> {
return await this.prisma.marketData.findMany({
return await this.prismaService.marketData.findMany({
orderBy: [
{
date: 'asc'

View File

@ -170,7 +170,6 @@ describe('PortfolioCalculator', () => {
currency: Currency.USD,
firstBuyDate: '2019-02-01',
investment: new Big('1443.8'),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'),
symbol: 'VTI',
transactionCount: 1
@ -184,7 +183,6 @@ describe('PortfolioCalculator', () => {
currency: Currency.USD,
firstBuyDate: '2019-02-01',
investment: new Big('2923.7'),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'),
symbol: 'VTI',
transactionCount: 2
@ -198,7 +196,6 @@ describe('PortfolioCalculator', () => {
currency: Currency.USD,
firstBuyDate: '2019-02-01',
investment: new Big('652.55'),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('5'),
symbol: 'VTI',
transactionCount: 3
@ -212,7 +209,6 @@ describe('PortfolioCalculator', () => {
currency: Currency.USD,
firstBuyDate: '2019-02-01',
investment: new Big('6627.05'),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('35'),
symbol: 'VTI',
transactionCount: 5
@ -226,7 +222,6 @@ describe('PortfolioCalculator', () => {
currency: Currency.USD,
firstBuyDate: '2019-02-01',
investment: new Big('8403.95'),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('45'),
symbol: 'VTI',
transactionCount: 6
@ -262,7 +257,6 @@ describe('PortfolioCalculator', () => {
date: '2019-02-01',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'),
symbol: 'VTI',
investment: new Big('1443.8'),
@ -276,7 +270,6 @@ describe('PortfolioCalculator', () => {
date: '2019-08-03',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'),
symbol: 'VTI',
investment: new Big('2923.7'),
@ -290,7 +283,6 @@ describe('PortfolioCalculator', () => {
date: '2019-09-01',
items: [
{
name: 'Amazon.com, Inc.',
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
@ -299,7 +291,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1
},
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'),
symbol: 'VTI',
investment: new Big('2923.7'),
@ -313,7 +304,6 @@ describe('PortfolioCalculator', () => {
date: '2020-02-02',
items: [
{
name: 'Amazon.com, Inc.',
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
@ -322,7 +312,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1
},
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('652.55'),
@ -336,7 +325,6 @@ describe('PortfolioCalculator', () => {
date: '2021-02-01',
items: [
{
name: 'Amazon.com, Inc.',
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
@ -345,7 +333,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1
},
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('15'),
symbol: 'VTI',
investment: new Big('2684.05'),
@ -359,7 +346,6 @@ describe('PortfolioCalculator', () => {
date: '2021-08-01',
items: [
{
name: 'Amazon.com, Inc.',
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
@ -368,7 +354,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1
},
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('25'),
symbol: 'VTI',
investment: new Big('4460.95'),
@ -411,124 +396,9 @@ describe('PortfolioCalculator', () => {
const portfolioItemsAtTransactionPoints =
portfolioCalculator.getTransactionPoints();
expect(portfolioItemsAtTransactionPoints).toEqual([
{
date: '2019-02-01',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'),
symbol: 'VTI',
investment: new Big('1443.8'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 1
}
]
},
{
date: '2019-08-03',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'),
symbol: 'VTI',
investment: new Big('2923.7'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 2
}
]
},
{
date: '2019-09-01',
items: [
{
name: 'Amazon.com, Inc.',
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 1
},
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'),
symbol: 'VTI',
investment: new Big('2923.7'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 2
}
]
},
{
date: '2020-02-02',
items: [
{
name: 'Amazon.com, Inc.',
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 1
},
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('652.55'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 3
}
]
},
{
date: '2020-08-02',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('652.55'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 3
}
]
},
{
date: '2021-02-01',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('15'),
symbol: 'VTI',
investment: new Big('2684.05'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 4
}
]
},
{
date: '2021-08-01',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('25'),
symbol: 'VTI',
investment: new Big('4460.95'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 5
}
]
}
]);
expect(portfolioItemsAtTransactionPoints).toEqual(
transactionPointsBuyAndSell
);
});
it('with mixed symbols', () => {
@ -545,7 +415,6 @@ describe('PortfolioCalculator', () => {
date: '2017-01-03',
items: [
{
name: 'Tesla, Inc.',
quantity: new Big('50'),
symbol: 'TSLA',
investment: new Big('2148.5'),
@ -559,7 +428,6 @@ describe('PortfolioCalculator', () => {
date: '2017-07-01',
items: [
{
name: 'Bitcoin USD',
quantity: new Big('0.5614682'),
symbol: 'BTCUSD',
investment: new Big('1999.9999999999998659756'),
@ -568,7 +436,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1
},
{
name: 'Tesla, Inc.',
quantity: new Big('50'),
symbol: 'TSLA',
investment: new Big('2148.5'),
@ -582,7 +449,6 @@ describe('PortfolioCalculator', () => {
date: '2018-09-01',
items: [
{
name: 'Amazon.com, Inc.',
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
@ -591,7 +457,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1
},
{
name: 'Bitcoin USD',
quantity: new Big('0.5614682'),
symbol: 'BTCUSD',
investment: new Big('1999.9999999999998659756'),
@ -600,7 +465,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1
},
{
name: 'Tesla, Inc.',
quantity: new Big('50'),
symbol: 'TSLA',
investment: new Big('2148.5'),
@ -645,7 +509,6 @@ describe('PortfolioCalculator', () => {
grossPerformancePercentage: new Big('-0.08595335390431712673'), // (657.62-719.46)/719.46=-0.08595335390431712673
investment: new Big('719.46'),
marketPrice: 657.62,
name: 'Tesla, Inc.',
quantity: new Big('1'),
symbol: 'TSLA',
transactionCount: 1
@ -684,7 +547,6 @@ describe('PortfolioCalculator', () => {
grossPerformancePercentage: new Big('-0.08595335390431712673'), // (657.62-719.46)/719.46=-0.08595335390431712673
investment: new Big('719.46'),
marketPrice: 657.62,
name: 'Tesla, Inc.',
quantity: new Big('1'),
symbol: 'TSLA',
transactionCount: 1
@ -723,7 +585,6 @@ describe('PortfolioCalculator', () => {
grossPerformancePercentage: new Big('-0.01356013560135601356'), // 657.62/666.66-1=-0.013560136
investment: new Big('719.46'),
marketPrice: 657.62,
name: 'Tesla, Inc.',
quantity: new Big('1'),
symbol: 'TSLA',
transactionCount: 1
@ -765,7 +626,6 @@ describe('PortfolioCalculator', () => {
),
investment: new Big('4460.95'),
marketPrice: 194.86,
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('25'),
symbol: 'VTI',
transactionCount: 5
@ -774,6 +634,138 @@ describe('PortfolioCalculator', () => {
});
});
it('with buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator(
currentRateService,
Currency.USD
);
portfolioCalculator.setTransactionPoints(transactionPointsBuyAndSell);
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2019-01-01')
);
spy.mockRestore();
expect(currentPositions).toEqual({
hasErrors: false,
currentValue: new Big('4871.5'),
grossPerformance: new Big('240.4'),
grossPerformancePercentage: new Big('0.01104605615757711361'),
totalInvestment: new Big('4460.95'),
positions: [
{
averagePrice: new Big('0'),
currency: 'USD',
firstBuyDate: '2019-09-01',
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
investment: new Big('0'),
marketPrice: 2021.99,
quantity: new Big('0'),
symbol: 'AMZN',
transactionCount: 2
},
{
averagePrice: new Big('178.438'),
currency: 'USD',
firstBuyDate: '2019-02-01',
grossPerformance: new Big('240.4'),
grossPerformancePercentage: new Big(
'0.08839407904876477101219019935616297754969945667391763908415656216989674494965785538864363782688167989866968512455219637257546280462751601552'
),
investment: new Big('4460.95'),
marketPrice: 194.86,
quantity: new Big('25'),
symbol: 'VTI',
transactionCount: 5
}
]
});
});
it('with buy, sell, buy', async () => {
const portfolioCalculator = new PortfolioCalculator(
currentRateService,
Currency.USD
);
portfolioCalculator.setTransactionPoints([
{
date: '2019-09-01',
items: [
{
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('805.9'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 1
}
]
},
{
date: '2020-08-02',
items: [
{
quantity: new Big('0'),
symbol: 'VTI',
investment: new Big('0'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 2
}
]
},
{
date: '2021-02-01',
items: [
{
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('1013.9'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 3
}
]
}
]);
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => new Date(Date.UTC(2021, 7, 1)).getTime()); // 2021-08-01
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2019-02-01')
);
spy.mockRestore();
expect(currentPositions).toEqual({
hasErrors: false,
currentValue: new Big('1086.7'),
grossPerformance: new Big('207.6'),
grossPerformancePercentage: new Big('0.2516103956224511062'),
totalInvestment: new Big('1013.9'),
positions: [
{
averagePrice: new Big('202.78'),
currency: 'USD',
firstBuyDate: '2019-09-01',
grossPerformance: new Big('207.6'),
grossPerformancePercentage: new Big(
'0.2516103956224511061954915466429950404846'
),
investment: new Big('1013.9'),
marketPrice: 217.34,
quantity: new Big('5'),
symbol: 'VTI',
transactionCount: 3
}
]
});
});
it('with performance since Jan 1st, 2020', async () => {
const portfolioCalculator = new PortfolioCalculator(
currentRateService,
@ -847,7 +839,6 @@ describe('PortfolioCalculator', () => {
grossPerformancePercentage: new Big(
'0.2753783814827239834392742298083677500037'
),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
currency: 'USD'
}
]
@ -867,9 +858,8 @@ describe('PortfolioCalculator', () => {
date: '2010-12-31',
items: [
{
name: 'Mutual Fund A',
quantity: new Big('1000000'), // 1 million
symbol: 'MFA',
symbol: 'MFA', // Mutual Fund A
investment: new Big('1000000'), // 1 million
currency: Currency.USD,
firstBuyDate: '2010-12-31',
@ -881,9 +871,8 @@ describe('PortfolioCalculator', () => {
date: '2011-08-15',
items: [
{
name: 'Mutual Fund A',
quantity: new Big('1086022.689344541'), // 1,000,000 + 100,000 / 1.162484
symbol: 'MFA',
symbol: 'MFA', // Mutual Fund A
investment: new Big('1100000'), // 1,000,000 + 100,000
currency: Currency.USD,
firstBuyDate: '2010-12-31',
@ -919,7 +908,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 2,
grossPerformance: new Big('92327.999656600898394721'), // 1'192'328 - 1'100'000 = 92'328
grossPerformancePercentage: new Big('0.09788498099999947808927632'), // 9.79 %
name: 'Mutual Fund A',
currency: 'USD'
}
]
@ -939,18 +927,16 @@ describe('PortfolioCalculator', () => {
date: '2012-12-31',
items: [
{
name: 'Sub Portfolio A',
quantity: new Big('200'),
symbol: 'SPA',
symbol: 'SPA', // Sub Portfolio A
investment: new Big('200'),
currency: Currency.CHF,
firstBuyDate: '2012-12-31',
transactionCount: 1
},
{
name: 'Sub Portfolio B',
quantity: new Big('300'),
symbol: 'SPB',
symbol: 'SPB', // Sub Portfolio B
investment: new Big('300'),
currency: Currency.CHF,
firstBuyDate: '2012-12-31',
@ -962,18 +948,16 @@ describe('PortfolioCalculator', () => {
date: '2013-12-31',
items: [
{
name: 'Sub Portfolio A',
quantity: new Big('200'),
symbol: 'SPA',
symbol: 'SPA', // Sub Portfolio A
investment: new Big('200'),
currency: Currency.CHF,
firstBuyDate: '2012-12-31',
transactionCount: 1
},
{
name: 'Sub Portfolio B',
quantity: new Big('300'),
symbol: 'SPB',
symbol: 'SPB', // Sub Portfolio B
investment: new Big('300'),
currency: Currency.CHF,
firstBuyDate: '2012-12-31',
@ -1009,7 +993,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1,
grossPerformance: new Big('5'), // 205 - 200
grossPerformancePercentage: new Big('0.025'),
name: 'Sub Portfolio A',
currency: 'CHF'
},
{
@ -1022,7 +1005,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1,
grossPerformance: new Big('12'), // 312 - 300
grossPerformancePercentage: new Big('0.04'),
name: 'Sub Portfolio B',
currency: 'CHF'
}
]
@ -1611,7 +1593,6 @@ describe('PortfolioCalculator', () => {
date: '2019-02-01',
items: [
{
name: 'Amazon.com, Inc.',
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
@ -1620,7 +1601,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1
},
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'),
symbol: 'VTI',
investment: new Big('1443.8'),
@ -1744,7 +1724,6 @@ const orderTslaTransactionPoint: TransactionPoint[] = [
date: '2021-01-01',
items: [
{
name: 'Tesla, Inc.',
quantity: new Big('1'),
symbol: 'TSLA',
investment: new Big('719.46'),
@ -1761,7 +1740,6 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
date: '2019-02-01',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'),
symbol: 'VTI',
investment: new Big('1443.8'),
@ -1775,7 +1753,6 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
date: '2019-08-03',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'),
symbol: 'VTI',
investment: new Big('2923.7'),
@ -1789,7 +1766,6 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
date: '2020-02-02',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('652.55'),
@ -1803,7 +1779,6 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
date: '2021-02-01',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('15'),
symbol: 'VTI',
investment: new Big('2684.05'),
@ -1817,7 +1792,140 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
date: '2021-08-01',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('25'),
symbol: 'VTI',
investment: new Big('4460.95'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 5
}
]
}
];
const transactionPointsBuyAndSell = [
{
date: '2019-02-01',
items: [
{
quantity: new Big('10'),
symbol: 'VTI',
investment: new Big('1443.8'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 1
}
]
},
{
date: '2019-08-03',
items: [
{
quantity: new Big('20'),
symbol: 'VTI',
investment: new Big('2923.7'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 2
}
]
},
{
date: '2019-09-01',
items: [
{
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 1
},
{
quantity: new Big('20'),
symbol: 'VTI',
investment: new Big('2923.7'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 2
}
]
},
{
date: '2020-02-02',
items: [
{
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 1
},
{
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('652.55'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 3
}
]
},
{
date: '2020-08-02',
items: [
{
quantity: new Big('0'),
symbol: 'AMZN',
investment: new Big('0'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 2
},
{
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('652.55'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 3
}
]
},
{
date: '2021-02-01',
items: [
{
quantity: new Big('0'),
symbol: 'AMZN',
investment: new Big('0'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 2
},
{
quantity: new Big('15'),
symbol: 'VTI',
investment: new Big('2684.05'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 4
}
]
},
{
date: '2021-08-01',
items: [
{
quantity: new Big('0'),
symbol: 'AMZN',
investment: new Big('0'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 2
},
{
quantity: new Big('25'),
symbol: 'VTI',
investment: new Big('4460.95'),

View File

@ -23,8 +23,7 @@ import {
isAfter,
isBefore,
max,
min,
subDays
min
} from 'date-fns';
import { flatten } from 'lodash';
@ -53,17 +52,19 @@ export class PortfolioCalculator {
const factor = this.getFactor(order.type);
const unitPrice = new Big(order.unitPrice);
if (oldAccumulatedSymbol) {
const newQuantity = order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
currentTransactionPointItem = {
currency: order.currency,
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: unitPrice
.mul(order.quantity)
.mul(factor)
.add(oldAccumulatedSymbol.investment),
name: order.name,
quantity: order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity),
investment: newQuantity.eq(0)
? new Big(0)
: unitPrice
.mul(order.quantity)
.mul(factor)
.add(oldAccumulatedSymbol.investment),
quantity: newQuantity,
symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
@ -72,7 +73,6 @@ export class PortfolioCalculator {
currency: order.currency,
firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor),
name: order.name,
quantity: order.quantity.mul(factor),
symbol: order.symbol,
transactionCount: 1
@ -85,11 +85,7 @@ export class PortfolioCalculator {
const newItems = items.filter(
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
);
if (!currentTransactionPointItem.quantity.eq(0)) {
newItems.push(currentTransactionPointItem);
} else {
delete symbols[order.symbol];
}
newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
if (lastDate !== currentDate || lastTransactionPoint === null) {
lastTransactionPoint = {
@ -234,42 +230,46 @@ export class PortfolioCalculator {
if (i === firstIndex || !initialValues[item.symbol]) {
initialValues[item.symbol] = initialValue;
}
if (!initialValue) {
invalidSymbols.push(item.symbol);
hasErrors = true;
console.error(
`Missing value for symbol ${item.symbol} at ${currentDate}`
if (!item.quantity.eq(0)) {
if (!initialValue) {
invalidSymbols.push(item.symbol);
hasErrors = true;
console.error(
`Missing value for symbol ${item.symbol} at ${currentDate}`
);
continue;
}
const cashFlow = lastInvestment;
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
item.quantity
);
continue;
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
holdingPeriodReturns[item.symbol] =
oldHoldingPeriodReturn.mul(holdingPeriodReturn);
let oldGrossPerformance = grossPerformance[item.symbol];
if (!oldGrossPerformance) {
oldGrossPerformance = new Big(0);
}
const currentPerformance = endValue.minus(investedValue);
grossPerformance[item.symbol] =
oldGrossPerformance.plus(currentPerformance);
}
const cashFlow = lastInvestment;
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
item.quantity
);
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
holdingPeriodReturns[item.symbol] =
oldHoldingPeriodReturn.mul(holdingPeriodReturn);
let oldGrossPerformance = grossPerformance[item.symbol];
if (!oldGrossPerformance) {
oldGrossPerformance = new Big(0);
}
const currentPerformance = endValue.minus(investedValue);
grossPerformance[item.symbol] =
oldGrossPerformance.plus(currentPerformance);
lastInvestments[item.symbol] = item.investment;
lastQuantities[item.symbol] = item.quantity;
}
}
const positions: TimelinePosition[] = [];
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const isValid = invalidSymbols.indexOf(item.symbol) === -1;
positions.push({
averagePrice: item.investment.div(item.quantity),
averagePrice: item.quantity.eq(0)
? new Big(0)
: item.investment.div(item.quantity),
currency: item.currency,
firstBuyDate: item.firstBuyDate,
grossPerformance: isValid
@ -281,7 +281,6 @@ export class PortfolioCalculator {
: null,
investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null,
name: item.name,
quantity: item.quantity,
symbol: item.symbol,
transactionCount: item.transactionCount
@ -401,7 +400,7 @@ export class PortfolioCalculator {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
} else {
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
@ -414,20 +413,25 @@ export class PortfolioCalculator {
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
);
} else {
} else if (!currentPosition.quantity.eq(0)) {
console.error(
`Initial value is missing for symbol ${currentPosition.symbol}`
);
hasErrors = true;
}
}
if (!completeInitialValue.eq(0)) {
grossPerformancePercentage =
grossPerformancePercentage.div(completeInitialValue);
}
return {
currentValue,
grossPerformance,
grossPerformancePercentage,
hasErrors,
totalInvestment,
grossPerformancePercentage:
grossPerformancePercentage.div(completeInitialValue)
totalInvestment
};
}

View File

@ -11,12 +11,12 @@ export class ExperimentalService {
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private prisma: PrismaService,
private readonly prismaService: PrismaService,
private readonly rulesService: RulesService
) {}
public async getBenchmark(aSymbol: string) {
return this.prisma.marketData.findMany({
return this.prismaService.marketData.findMany({
orderBy: { date: 'asc' },
select: { date: true, marketPrice: true },
where: { symbol: aSymbol }

View File

@ -5,10 +5,10 @@ import { Injectable } from '@nestjs/common';
@Injectable()
export class ExportService {
public constructor(private prisma: PrismaService) {}
public constructor(private readonly prismaService: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> {
const orders = await this.prisma.order.findMany({
const orders = await this.prismaService.order.findMany({
orderBy: { date: 'desc' },
select: {
currency: true,

View File

@ -24,20 +24,17 @@ export class ImportService {
type,
unitPrice
} of orders) {
await this.orderService.createOrder(
{
currency,
dataSource,
fee,
quantity,
symbol,
type,
unitPrice,
date: parseISO(<string>(<unknown>date)),
User: { connect: { id: userId } }
},
userId
);
await this.orderService.createOrder({
currency,
dataSource,
fee,
quantity,
symbol,
type,
unitPrice,
date: parseISO(<string>(<unknown>date)),
User: { connect: { id: userId } }
});
}
}
}

View File

@ -1,4 +1,10 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@ -14,6 +20,16 @@ import { InfoService } from './info.service';
})
],
controllers: [InfoController],
providers: [ConfigurationService, InfoService, PrismaService]
providers: [
AlphaVantageService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
InfoService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class InfoModule {}

View File

@ -1,4 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
@ -15,13 +16,14 @@ export class InfoService {
public constructor(
private readonly configurationService: ConfigurationService,
private jwtService: JwtService,
private prisma: PrismaService
private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService
) {}
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
const platforms = await this.prisma.platform.findMany({
const platforms = await this.prismaService.platform.findMany({
orderBy: { name: 'asc' },
select: { id: true, name: true }
});
@ -63,7 +65,7 @@ export class InfoService {
}
private async countActiveUsers(aDays: number) {
return await this.prisma.user.count({
return await this.prismaService.user.count({
orderBy: {
Analytics: {
updatedAt: 'desc'
@ -116,11 +118,10 @@ export class InfoService {
}
private async getLastDataGathering() {
const lastDataGathering = await this.prisma.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' }
});
const lastDataGathering =
await this.dataGatheringService.getLastDataGathering();
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null;
return lastDataGathering ?? null;
}
private async getStatistics() {
@ -144,7 +145,7 @@ export class InfoService {
return undefined;
}
const stripeConfig = await this.prisma.property.findUnique({
const stripeConfig = await this.prismaService.property.findUnique({
where: { key: 'STRIPE_CONFIG' }
});

View File

@ -52,15 +52,12 @@ export class OrderController {
);
}
return this.orderService.deleteOrder(
{
id_userId: {
id,
userId: this.request.user.id
}
},
this.request.user.id
);
return this.orderService.deleteOrder({
id_userId: {
id,
userId: this.request.user.id
}
});
}
@Get()
@ -80,6 +77,11 @@ export class OrderController {
include: {
Platform: true
}
},
SymbolProfile: {
select: {
name: true
}
}
},
orderBy: { date: 'desc' },
@ -130,33 +132,30 @@ export class OrderController {
const accountId = data.accountId;
delete data.accountId;
return this.orderService.createOrder(
{
...data,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
date,
SymbolProfile: {
connectOrCreate: {
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
return this.orderService.createOrder({
...data,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
date,
SymbolProfile: {
connectOrCreate: {
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
User: { connect: { id: this.request.user.id } }
}
},
this.request.user.id
);
User: { connect: { id: this.request.user.id } }
});
}
@Put(':id')
@ -193,26 +192,23 @@ export class OrderController {
const accountId = data.accountId;
delete data.accountId;
return this.orderService.updateOrder(
{
data: {
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
return this.orderService.updateOrder({
data: {
...data,
date,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
}
},
User: { connect: { id: this.request.user.id } }
},
this.request.user.id
);
where: {
id_userId: {
id,
userId: this.request.user.id
}
}
});
}
}

View File

@ -6,21 +6,19 @@ import { DataSource, Order, Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns';
import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
@Injectable()
export class OrderService {
public constructor(
private readonly cacheService: CacheService,
private readonly dataGatheringService: DataGatheringService,
private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService
private readonly prismaService: PrismaService
) {}
public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> {
return this.prisma.order.findUnique({
return this.prismaService.order.findUnique({
where: orderWhereUniqueInput
});
}
@ -35,7 +33,7 @@ export class OrderService {
}): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.order.findMany({
return this.prismaService.order.findMany({
cursor,
include,
orderBy,
@ -45,13 +43,10 @@ export class OrderService {
});
}
public async createOrder(
data: Prisma.OrderCreateInput,
aUserId: string
): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
const isDraft = isAfter(data.date as Date, endOfToday());
if (!isAfter(data.date as Date, endOfToday())) {
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
@ -64,48 +59,75 @@ export class OrderService {
this.dataGatheringService.gatherProfileData([data.symbol]);
await this.cacheService.flush(aUserId);
await this.cacheService.flush();
return this.prisma.order.create({
data
return this.prismaService.order.create({
data: {
...data,
isDraft
}
});
}
public async deleteOrder(
where: Prisma.OrderWhereUniqueInput,
aUserId: string
where: Prisma.OrderWhereUniqueInput
): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
return this.prisma.order.delete({
return this.prismaService.order.delete({
where
});
}
public async updateOrder(
params: {
where: Prisma.OrderWhereUniqueInput;
data: Prisma.OrderUpdateInput;
},
aUserId: string
): Promise<Order> {
public getOrders({
includeDrafts = false,
userId
}: {
includeDrafts?: boolean;
userId: string;
}) {
const where: Prisma.OrderWhereInput = { userId };
if (includeDrafts === false) {
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' }
});
}
public async updateOrder(params: {
where: Prisma.OrderWhereUniqueInput;
data: Prisma.OrderUpdateInput;
}): Promise<Order> {
const { data, where } = params;
this.redisCacheService.remove(`${aUserId}.portfolio`);
const isDraft = isAfter(data.date as Date, endOfToday());
// Gather symbol data of order in the background
this.dataGatheringService.gatherSymbols([
{
dataSource: <DataSource>data.dataSource,
date: <Date>data.date,
symbol: <string>data.symbol
}
]);
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: <DataSource>data.dataSource,
date: <Date>data.date,
symbol: <string>data.symbol
}
]);
}
await this.cacheService.flush(aUserId);
await this.cacheService.flush();
return this.prisma.order.update({
data,
return this.prismaService.order.update({
data: {
...data,
isDraft
},
where
});
}

View File

@ -5,11 +5,10 @@ import {
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import {
PortfolioItem,
PortfolioOverview,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport
PortfolioReport,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import {
@ -31,6 +30,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import Big from 'big.js';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -203,32 +203,6 @@ export class PortfolioController {
return <any>res.json(details);
}
@Get('overview')
@UseGuards(AuthGuard('jwt'))
public async getOverview(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioOverview> {
let overview = await this.portfolioService.getOverview(impersonationId);
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
overview = nullifyValuesInObject(overview, [
'cash',
'committedFunds',
'fees',
'totalBuy',
'totalSell'
]);
}
return overview;
}
@Get('performance')
@UseGuards(AuthGuard('jwt'))
public async getPerformance(
@ -282,6 +256,35 @@ export class PortfolioController {
return <any>res.json(result);
}
@Get('summary')
@UseGuards(AuthGuard('jwt'))
public async getSummary(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> {
let summary = await this.portfolioService.getSummary(impersonationId);
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
summary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentNetPerformance',
'currentValue',
'fees',
'totalBuy',
'totalSell'
]);
}
return summary;
}
@Get('position/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getPosition(

View File

@ -1,4 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
@ -17,16 +18,17 @@ import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
PortfolioOverview,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport,
PortfolioSummary,
Position,
TimelinePosition
} from '@ghostfolio/common/interfaces';
@ -38,7 +40,12 @@ import {
} from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { Currency, DataSource, Type as TypeOfOrder } from '@prisma/client';
import {
AssetClass,
Currency,
DataSource,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js';
import {
endOfToday,
@ -83,7 +90,10 @@ export class PortfolioService {
this.request.user.Settings.currency
);
const { transactionPoints } = await this.getTransactionPoints(userId);
const { transactionPoints } = await this.getTransactionPoints({
userId,
includeDrafts: true
});
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return [];
@ -108,7 +118,7 @@ export class PortfolioService {
this.request.user.Settings.currency
);
const { transactionPoints } = await this.getTransactionPoints(userId);
const { transactionPoints } = await this.getTransactionPoints({ userId });
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return [];
@ -141,31 +151,6 @@ export class PortfolioService {
}));
}
public async getOverview(
aImpersonationId: string
): Promise<PortfolioOverview> {
const userId = await this.getUserId(aImpersonationId);
const currency = this.request.user.Settings.currency;
const { balance } = await this.accountService.getCashDetails(
userId,
currency
);
const orders = await this.getOrders(userId);
const fees = this.getFees(orders);
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
return {
committedFunds: totalBuy - totalSell,
fees,
cash: balance,
ordersCount: orders.length,
totalBuy: totalBuy,
totalSell: totalSell
};
}
public async getDetails(
aImpersonationId: string,
aDateRange: DateRange = 'max'
@ -178,9 +163,9 @@ export class PortfolioService {
userCurrency
);
const { transactionPoints, orders } = await this.getTransactionPoints(
const { orders, transactionPoints } = await this.getTransactionPoints({
userId
);
});
if (transactionPoints?.length <= 0) {
return {};
@ -198,8 +183,16 @@ export class PortfolioService {
throw new Error('Missing information');
}
const cashDetails = await this.accountService.getCashDetails(
userId,
userCurrency
);
const result: { [symbol: string]: PortfolioPosition } = {};
const totalValue = currentPositions.currentValue;
const totalInvestment = currentPositions.totalInvestment.plus(
cashDetails.balance
);
const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
const symbols = currentPositions.positions.map(
(position) => position.symbol
@ -228,9 +221,8 @@ export class PortfolioService {
result[item.symbol] = {
accounts,
allocationCurrent: value.div(totalValue).toNumber(),
allocationInvestment: item.investment
.div(currentPositions.totalInvestment)
.toNumber(),
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
assetClass: symbolProfile.assetClass,
countries: symbolProfile.countries,
currency: item.currency,
exchange: dataProviderResponse.exchange,
@ -239,16 +231,22 @@ export class PortfolioService {
investment: item.investment.toNumber(),
marketPrice: item.marketPrice,
marketState: dataProviderResponse.marketState,
name: item.name,
name: symbolProfile.name,
quantity: item.quantity.toNumber(),
sectors: symbolProfile.sectors,
symbol: item.symbol,
transactionCount: item.transactionCount,
type: dataProviderResponse.type,
value: value.toNumber()
};
}
// TODO: Add a cash position for each currency
result[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails,
investment: totalInvestment,
value: totalValue
});
return result;
}
@ -258,14 +256,11 @@ export class PortfolioService {
): Promise<PortfolioPositionDetail> {
const userId = await this.getUserId(aImpersonationId);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
const orders = (await this.orderService.getOrders({ userId })).filter(
(order) => order.symbol === aSymbol
);
const { transactionPoints } = await this.getTransactionPoints(userId);
if (transactionPoints?.length <= 0) {
if (orders.length <= 0) {
return {
averagePrice: undefined,
currency: undefined,
@ -283,7 +278,24 @@ export class PortfolioService {
};
}
portfolioCalculator.setTransactionPoints(transactionPoints);
const positionCurrency = orders[0].currency;
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
date: format(order.date, DATE_FORMAT),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
type: <OrderType>order.type,
unitPrice: new Big(order.unitPrice)
}));
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
positionCurrency
);
portfolioCalculator.computeTransactionPoints(portfolioOrders);
const transactionPoints = portfolioCalculator.getTransactionPoints();
const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions(
@ -299,12 +311,24 @@ export class PortfolioService {
averagePrice,
currency,
firstBuyDate,
investment,
marketPrice,
quantity,
transactionCount
} = position;
// Convert investment and gross performance to currency of user
const userCurrency = this.request.user.Settings.currency;
const investment = this.exchangeRateDataService.toCurrency(
position.investment.toNumber(),
currency,
userCurrency
);
const grossPerformance = this.exchangeRateDataService.toCurrency(
position.grossPerformance.toNumber(),
currency,
userCurrency
);
const historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
'day',
@ -313,8 +337,17 @@ export class PortfolioService {
);
const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = marketPrice;
let minPrice = marketPrice;
let maxPrice = orders[0].unitPrice;
let minPrice = orders[0].unitPrice;
if (!historicalData[aSymbol][firstBuyDate]) {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: orders[0].unitPrice,
date: firstBuyDate,
value: orders[0].unitPrice
});
}
if (historicalData[aSymbol]) {
let j = -1;
@ -332,9 +365,9 @@ export class PortfolioService {
(item) => item.symbol === aSymbol
);
if (currentSymbol) {
currentAveragePrice = currentSymbol.investment
.div(currentSymbol.quantity)
.toNumber();
currentAveragePrice = currentSymbol.quantity.eq(0)
? 0
: currentSymbol.investment.div(currentSymbol.quantity).toNumber();
}
historicalDataArray.push({
@ -351,20 +384,21 @@ export class PortfolioService {
return {
currency,
firstBuyDate,
grossPerformance,
investment,
marketPrice,
maxPrice,
minPrice,
transactionCount,
averagePrice: averagePrice.toNumber(),
grossPerformance: position.grossPerformance.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
historicalData: historicalDataArray,
investment: investment.toNumber(),
quantity: quantity.toNumber(),
symbol: aSymbol
};
} else {
const currentData = await this.dataProviderService.get([aSymbol]);
const marketPrice = currentData[aSymbol]?.marketPrice;
let historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
@ -382,28 +416,33 @@ export class PortfolioService {
}
const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = marketPrice;
let minPrice = marketPrice;
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
).reverse()) {
)) {
historicalDataArray.push({
date,
value: marketPrice
});
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
}
return {
averagePrice: undefined,
marketPrice,
maxPrice,
minPrice,
averagePrice: 0,
currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: historicalDataArray,
investment: undefined,
marketPrice: currentData[aSymbol]?.marketPrice,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
investment: 0,
quantity: 0,
symbol: aSymbol,
transactionCount: undefined
};
@ -421,7 +460,7 @@ export class PortfolioService {
this.request.user.Settings.currency
);
const { transactionPoints } = await this.getTransactionPoints(userId);
const { transactionPoints } = await this.getTransactionPoints({ userId });
if (transactionPoints?.length <= 0) {
return {
@ -438,17 +477,34 @@ export class PortfolioService {
startDate
);
const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0)
);
const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(symbols),
this.symbolProfileService.getSymbolProfiles(symbols)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) {
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
}
return {
hasErrors: currentPositions.hasErrors,
positions: currentPositions.positions.map((position) => {
positions: positions.map((position) => {
return {
...position,
assetClass: symbolProfileMap[position.symbol].assetClass,
averagePrice: new Big(position.averagePrice).toNumber(),
grossPerformance: position.grossPerformance?.toNumber() ?? null,
grossPerformancePercentage:
position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(),
name: position.name,
marketState: dataProviderResponses[position.symbol].marketState,
name: symbolProfileMap[position.symbol].name,
quantity: new Big(position.quantity).toNumber()
};
})
@ -466,7 +522,7 @@ export class PortfolioService {
this.request.user.Settings.currency
);
const { transactionPoints } = await this.getTransactionPoints(userId);
const { transactionPoints } = await this.getTransactionPoints({ userId });
if (transactionPoints?.length <= 0) {
return {
@ -528,9 +584,9 @@ export class PortfolioService {
const userId = await this.getUserId(impersonationId);
const baseCurrency = this.request.user.Settings.currency;
const { transactionPoints, orders } = await this.getTransactionPoints(
const { orders, transactionPoints } = await this.getTransactionPoints({
userId
);
});
if (isEmpty(orders)) {
return {
@ -608,6 +664,82 @@ export class PortfolioService {
};
}
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId);
const performanceInformation = await this.getPerformance(userId);
const { balance } = await this.accountService.getCashDetails(
userId,
currency
);
const orders = await this.orderService.getOrders({ userId });
const fees = this.getFees(orders);
const firstOrderDate = orders[0]?.date;
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
const committedFunds = new Big(totalBuy).sub(totalSell);
const netWorth = new Big(balance)
.plus(performanceInformation.performance.currentValue)
.toNumber();
return {
...performanceInformation.performance,
fees,
firstOrderDate,
netWorth,
cash: balance,
committedFunds: committedFunds.toNumber(),
ordersCount: orders.length,
totalBuy: totalBuy,
totalSell: totalSell
};
}
private async getCashPosition({
cashDetails,
investment,
value
}: {
cashDetails: CashDetails;
investment: Big;
value: Big;
}) {
const accounts = {};
const cashValue = new Big(cashDetails.balance);
cashDetails.accounts.forEach((account) => {
accounts[account.name] = {
current: account.balance,
original: account.balance
};
});
return {
accounts,
allocationCurrent: cashValue.div(value).toNumber(),
allocationInvestment: cashValue.div(investment).toNumber(),
assetClass: AssetClass.CASH,
countries: [],
currency: Currency.CHF,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: cashValue.toNumber(),
marketPrice: 0,
marketState: MarketState.open,
name: 'Cash',
quantity: 0,
sectors: [],
symbol: ghostfolioCashSymbol,
transactionCount: 0,
value: cashValue.toNumber()
};
}
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) {
case '1d':
@ -626,11 +758,17 @@ export class PortfolioService {
return portfolioStart;
}
private async getTransactionPoints(userId: string): Promise<{
private async getTransactionPoints({
includeDrafts = false,
userId
}: {
includeDrafts?: boolean;
userId: string;
}): Promise<{
transactionPoints: TransactionPoint[];
orders: OrderWithAccount[];
}> {
const orders = await this.getOrders(userId);
const orders = await this.orderService.getOrders({ includeDrafts, userId });
if (orders.length <= 0) {
return { transactionPoints: [], orders: [] };
@ -702,19 +840,6 @@ export class PortfolioService {
return accounts;
}
private getOrders(aUserId: string) {
return this.orderService.orders({
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
},
orderBy: { date: 'asc' },
where: { userId: aUserId }
});
}
private async getUserId(aImpersonationId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(

View File

@ -10,7 +10,7 @@ export class SubscriptionService {
public constructor(
private readonly configurationService: ConfigurationService,
private prisma: PrismaService
private readonly prismaService: PrismaService
) {
this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'),
@ -68,7 +68,7 @@ export class SubscriptionService {
aCheckoutSessionId
);
await this.prisma.subscription.create({
await this.prismaService.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
User: {

View File

@ -1,6 +1,5 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Currency, DataSource } from '@prisma/client';
@ -11,7 +10,7 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService
private readonly prismaService: PrismaService
) {}
public async get(aSymbol: string): Promise<SymbolItem> {
@ -37,16 +36,28 @@ export class SymbolService {
results.items = items;
// Add custom symbols
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
scraperConfigurations.forEach((scraperConfiguration) => {
if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) {
results.items.push({
dataSource: DataSource.GHOSTFOLIO,
name: scraperConfiguration.name,
symbol: scraperConfiguration.symbol
});
}
});
const ghostfolioSymbolProfiles =
await this.prismaService.symbolProfile.findMany({
select: {
dataSource: true,
name: true,
symbol: true
},
where: {
AND: [
{
dataSource: DataSource.GHOSTFOLIO,
name: {
startsWith: aQuery
}
}
]
}
});
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
results.items.push(ghostfolioSymbolProfile);
}
return results;
} catch (error) {

View File

@ -18,7 +18,7 @@ export class UserService {
public constructor(
private readonly configurationService: ConfigurationService,
private prisma: PrismaService
private readonly prismaService: PrismaService
) {}
public async getUser({
@ -29,7 +29,7 @@ export class UserService {
Settings,
subscription
}: UserWithSettings): Promise<IUser> {
const access = await this.prisma.access.findMany({
const access = await this.prismaService.access.findMany({
include: {
User: true
},
@ -60,7 +60,7 @@ export class UserService {
public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
const userFromDatabase = await this.prisma.user.findUnique({
const userFromDatabase = await this.prismaService.user.findUnique({
include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput
});
@ -129,7 +129,7 @@ export class UserService {
orderBy?: Prisma.UserOrderByInput;
}): Promise<User[]> {
const { skip, take, cursor, where, orderBy } = params;
return this.prisma.user.findMany({
return this.prismaService.user.findMany({
skip,
take,
cursor,
@ -146,7 +146,7 @@ export class UserService {
}
public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
let user = await this.prisma.user.create({
let user = await this.prismaService.user.create({
data: {
...data,
Account: {
@ -169,7 +169,7 @@ export class UserService {
process.env.ACCESS_TOKEN_SALT
);
user = await this.prisma.user.update({
user = await this.prismaService.user.update({
data: { accessToken: hashedAccessToken },
where: { id: user.id }
});
@ -185,36 +185,36 @@ export class UserService {
data: Prisma.UserUpdateInput;
}): Promise<User> {
const { where, data } = params;
return this.prisma.user.update({
return this.prismaService.user.update({
data,
where
});
}
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
await this.prisma.access.deleteMany({
await this.prismaService.access.deleteMany({
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
});
await this.prisma.account.deleteMany({
await this.prismaService.account.deleteMany({
where: { userId: where.id }
});
await this.prisma.analytics.delete({
await this.prismaService.analytics.delete({
where: { userId: where.id }
});
await this.prisma.order.deleteMany({
await this.prismaService.order.deleteMany({
where: { userId: where.id }
});
try {
await this.prisma.settings.delete({
await this.prismaService.settings.delete({
where: { userId: where.id }
});
} catch {}
return this.prisma.user.delete({
return this.prismaService.user.delete({
where
});
}
@ -224,7 +224,7 @@ export class UserService {
userId,
viewMode
}: UserSettingsParams) {
await this.prisma.settings.upsert({
await this.prismaService.settings.upsert({
create: {
currency,
User: {

View File

@ -1,5 +1,4 @@
import { Account, Currency, SymbolProfile } from '@prisma/client';
import { endOfToday, isAfter, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces';
@ -11,6 +10,7 @@ export class Order {
private fee: number;
private date: string;
private id: string;
private isDraft: boolean;
private quantity: number;
private symbol: string;
private symbolProfile: SymbolProfile;
@ -24,6 +24,7 @@ export class Order {
this.fee = data.fee;
this.date = data.date;
this.id = data.id || uuidv4();
this.isDraft = data.isDraft;
this.quantity = data.quantity;
this.symbol = data.symbol;
this.symbolProfile = data.symbolProfile;
@ -54,7 +55,7 @@ export class Order {
}
public getIsDraft() {
return isAfter(parseISO(this.date), endOfToday());
return this.isDraft;
}
public getQuantity() {

View File

@ -3,7 +3,6 @@ import {
DATE_FORMAT,
getUtc,
isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol,
resetHours
} from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
@ -31,7 +30,7 @@ export class DataGatheringService {
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
private prisma: PrismaService
private readonly prismaService: PrismaService
) {}
public async gather7Days() {
@ -41,7 +40,7 @@ export class DataGatheringService {
console.log('7d data gathering has been started.');
console.time('7d-data-gathering');
await this.prisma.property.create({
await this.prismaService.property.create({
data: {
key: 'LOCKED_DATA_GATHERING',
value: new Date().toISOString()
@ -53,7 +52,7 @@ export class DataGatheringService {
try {
await this.gatherSymbols(symbols);
await this.prisma.property.upsert({
await this.prismaService.property.upsert({
create: {
key: 'LAST_DATA_GATHERING',
value: new Date().toISOString()
@ -65,7 +64,7 @@ export class DataGatheringService {
console.error(error);
}
await this.prisma.property.delete({
await this.prismaService.property.delete({
where: {
key: 'LOCKED_DATA_GATHERING'
}
@ -77,7 +76,7 @@ export class DataGatheringService {
}
public async gatherMax() {
const isDataGatheringLocked = await this.prisma.property.findUnique({
const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' }
});
@ -85,7 +84,7 @@ export class DataGatheringService {
console.log('Max data gathering has been started.');
console.time('max-data-gathering');
await this.prisma.property.create({
await this.prismaService.property.create({
data: {
key: 'LOCKED_DATA_GATHERING',
value: new Date().toISOString()
@ -97,7 +96,7 @@ export class DataGatheringService {
try {
await this.gatherSymbols(symbols);
await this.prisma.property.upsert({
await this.prismaService.property.upsert({
create: {
key: 'LAST_DATA_GATHERING',
value: new Date().toISOString()
@ -109,7 +108,7 @@ export class DataGatheringService {
console.error(error);
}
await this.prisma.property.delete({
await this.prismaService.property.delete({
where: {
key: 'LOCKED_DATA_GATHERING'
}
@ -135,18 +134,21 @@ export class DataGatheringService {
const currentData = await this.dataProviderService.get(symbols);
for (const [symbol, { currency, dataSource, name }] of Object.entries(
currentData
)) {
for (const [
symbol,
{ assetClass, currency, dataSource, name }
] of Object.entries(currentData)) {
try {
await this.prisma.symbolProfile.upsert({
await this.prismaService.symbolProfile.upsert({
create: {
assetClass,
currency,
dataSource,
name,
symbol
},
update: {
assetClass,
currency,
name
},
@ -203,7 +205,7 @@ export class DataGatheringService {
}
try {
await this.prisma.marketData.create({
await this.prismaService.marketData.create({
data: {
symbol,
date: currentDate,
@ -248,6 +250,34 @@ export class DataGatheringService {
});
}
public async getIsInProgress() {
return await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' }
});
}
public async getLastDataGathering() {
const lastDataGathering = await this.prismaService.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' }
});
if (lastDataGathering?.value) {
return new Date(lastDataGathering.value);
}
return undefined;
}
public async reset() {
console.log('Data gathering has been reset.');
await this.prismaService.property.deleteMany({
where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
}
});
}
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
return {
@ -271,7 +301,7 @@ export class DataGatheringService {
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prisma.order.findMany({
const distinctOrders = await this.prismaService.order.findMany({
distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true },
@ -303,9 +333,8 @@ export class DataGatheringService {
}
);
const customSymbolsToGather = await this.getCustomSymbolsToGather(
startDate
);
const customSymbolsToGather =
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
return [
...this.getBenchmarksToGather(startDate),
@ -318,9 +347,8 @@ export class DataGatheringService {
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate = new Date(getUtc('2015-01-01'));
const customSymbolsToGather = await this.getCustomSymbolsToGather(
startDate
);
const customSymbolsToGather =
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
const currencyPairsToGather = currencyPairs.map(
({ dataSource, symbol }) => {
@ -332,7 +360,7 @@ export class DataGatheringService {
}
);
const distinctOrders = await this.prisma.order.findMany({
const distinctOrders = await this.prismaService.order.findMany({
distinct: ['symbol'],
orderBy: [{ date: 'asc' }],
select: { dataSource: true, date: true, symbol: true },
@ -354,7 +382,7 @@ export class DataGatheringService {
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prisma.order.findMany({
const distinctOrders = await this.prismaService.order.findMany({
distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true }
@ -371,18 +399,13 @@ export class DataGatheringService {
}
private async isDataGatheringNeeded() {
const lastDataGathering = await this.prisma.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' }
});
const lastDataGathering = await this.getLastDataGathering();
const isDataGatheringLocked = await this.prisma.property.findUnique({
const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' }
});
const diffInHours = differenceInHours(
new Date(),
new Date(lastDataGathering?.value)
);
const diffInHours = differenceInHours(new Date(), lastDataGathering);
return (diffInHours >= 1 || !lastDataGathering) && !isDataGatheringLocked;
}

View File

@ -26,11 +26,11 @@ export class DataProviderService {
private readonly alphaVantageService: AlphaVantageService,
private readonly configurationService: ConfigurationService,
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService,
private prisma: PrismaService,
private readonly prismaService: PrismaService,
private readonly rakutenRapidApiService: RakutenRapidApiService,
private readonly yahooFinanceService: YahooFinanceService
) {
this.rakutenRapidApiService?.setPrisma(this.prisma);
this.rakutenRapidApiService?.setPrisma(this.prismaService);
}
public async get(
@ -112,9 +112,8 @@ export class DataProviderService {
`','`
)}') ${granularityQuery} ${rangeQuery} ORDER BY date;`;
const marketDataByGranularity: MarketData[] = await this.prisma.$queryRaw(
queryRaw
);
const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRaw(queryRaw);
response = marketDataByGranularity.reduce((r, marketData) => {
const { date, marketPrice, symbol } = marketData;

View File

@ -12,6 +12,7 @@ import { format } from 'date-fns';
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
@ -23,7 +24,7 @@ import { ScraperConfig } from './interfaces/scraper-config.interface';
export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
public constructor(private prisma: PrismaService) {}
public constructor(private readonly prismaService: PrismaService) {}
public canHandle(symbol: string) {
return isGhostfolioScraperApiSymbol(symbol);
@ -41,7 +42,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol);
const { marketPrice } = await this.prisma.marketData.findFirst({
const { marketPrice } = await this.prismaService.marketData.findFirst({
orderBy: {
date: 'desc'
},
@ -55,8 +56,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
marketPrice,
currency: scraperConfig?.currency,
dataSource: DataSource.GHOSTFOLIO,
marketState: MarketState.delayed,
name: scraperConfig?.name
marketState: MarketState.delayed
}
};
} catch (error) {
@ -66,6 +66,25 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return {};
}
public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const ghostfolioSymbolProfiles =
await this.prismaService.symbolProfile.findMany({
where: {
dataSource: DataSource.GHOSTFOLIO
}
});
return ghostfolioSymbolProfiles.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
}
public async getHistorical(
aSymbols: string[],
aGranularity: Granularity = 'day',
@ -111,7 +130,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
try {
const { value: scraperConfigString } =
await this.prisma.property.findFirst({
await this.prismaService.property.findFirst({
select: {
value: true
},

View File

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

View File

@ -23,7 +23,7 @@ import { PrismaService } from '../../prisma.service';
export class RakutenRapidApiService implements DataProviderInterface {
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
private prisma: PrismaService;
private prismaService: PrismaService;
public constructor(
private readonly configurationService: ConfigurationService
@ -89,7 +89,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
// TODO: can be removed after all data from the last year has been gathered
// (introduced on 27.03.2021)
await this.prisma.marketData.create({
await this.prismaService.marketData.create({
data: {
symbol,
date: subWeeks(getToday(), 1),
@ -97,7 +97,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
}
});
await this.prisma.marketData.create({
await this.prismaService.marketData.create({
data: {
symbol,
date: subMonths(getToday(), 1),
@ -105,7 +105,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
}
});
await this.prisma.marketData.create({
await this.prismaService.marketData.create({
data: {
symbol,
date: subYears(getToday(), 1),
@ -134,7 +134,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
}
public setPrisma(aPrismaService: PrismaService) {
this.prisma = aPrismaService;
this.prismaService = aPrismaService;
}
private async getFearAndGreedIndex(): Promise<{

View File

@ -8,7 +8,7 @@ import {
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { AssetClass, Currency, DataSource } from '@prisma/client';
import * as bent from 'bent';
import { format } from 'date-fns';
import * as yahooFinance from 'yahoo-finance';
@ -17,8 +17,7 @@ import { DataProviderInterface } from '../../interfaces/data-provider.interface'
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState,
Type
MarketState
} from '../../interfaces/interfaces';
import {
IYahooFinanceHistoricalResponse,
@ -61,6 +60,7 @@ export class YahooFinanceService implements DataProviderInterface {
const symbol = convertFromYahooSymbol(yahooSymbol);
response[symbol] = {
assetClass: this.parseAssetClass(value.price?.quoteType),
currency: parseCurrency(value.price?.currency),
dataSource: DataSource.YAHOO,
exchange: this.parseExchange(value.price?.exchangeName),
@ -69,8 +69,7 @@ export class YahooFinanceService implements DataProviderInterface {
? MarketState.open
: MarketState.closed,
marketPrice: value.price?.regularMarketPrice || 0,
name: value.price?.longName || value.price?.shortName || symbol,
type: this.parseType(this.getType(symbol, value))
name: value.price?.longName || value.price?.shortName || symbol
};
const url = value.summaryProfile?.website;
@ -148,8 +147,23 @@ export class YahooFinanceService implements DataProviderInterface {
200
);
const result = await get();
items = result.quotes
const searchResult = await get();
const symbols: string[] = searchResult.quotes
.filter((quote) => {
// filter out undefined symbols
return quote.symbol;
})
.filter(({ quoteType }) => {
return quoteType === 'EQUITY' || quoteType === 'ETF';
})
.map(({ symbol }) => {
return symbol;
});
const marketData = await this.get(symbols);
items = searchResult.quotes
.filter((quote) => {
return quote.isYahooFinance;
})
@ -163,7 +177,12 @@ export class YahooFinanceService implements DataProviderInterface {
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD
return symbol.includes('USD');
return symbol.includes(Currency.USD);
}
if (!marketData[symbol]?.currency) {
// Only allow symbols with supported currency
return false;
}
return true;
@ -203,14 +222,20 @@ export class YahooFinanceService implements DataProviderInterface {
return aSymbol;
}
private getType(aSymbol: string, aValue: IYahooFinanceQuoteResponse): Type {
if (isCrypto(aSymbol)) {
return Type.Cryptocurrency;
} else if (aValue.price?.quoteType.toLowerCase() === 'equity') {
return Type.Stock;
private parseAssetClass(aString: string): AssetClass {
let assetClass: AssetClass;
switch (aString?.toLowerCase()) {
case 'cryptocurrency':
assetClass = AssetClass.CASH;
break;
case 'equity':
case 'etf':
assetClass = AssetClass.EQUITY;
break;
}
return aValue.price?.quoteType.toLowerCase();
return assetClass;
}
private parseExchange(aString: string): string {
@ -220,18 +245,6 @@ export class YahooFinanceService implements DataProviderInterface {
return aString;
}
private parseType(aString: string): Type {
if (aString?.toLowerCase() === 'cryptocurrency') {
return Type.Cryptocurrency;
} else if (aString?.toLowerCase() === 'etf') {
return Type.ETF;
} else if (aString?.toLowerCase() === 'stock') {
return Type.Stock;
}
return Type.Unknown;
}
}
export const convertFromYahooSymbol = (aSymbol: string) => {

View File

@ -2,6 +2,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { format } from 'date-fns';
import { isNumber } from 'lodash';
import { DataProviderService } from './data-provider.service';
@ -83,7 +84,15 @@ export class ExchangeRateDataService {
factor = this.currencies[`${aFromCurrency}${aToCurrency}`];
}
return factor * aValue;
if (isNumber(factor)) {
return factor * aValue;
}
// Fallback with error, if currencies are not available
console.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
);
return aValue;
}
private addPairs(aCurrency1: Currency, aCurrency2: Currency) {

View File

@ -4,10 +4,10 @@ import { PrismaService } from './prisma.service';
@Injectable()
export class ImpersonationService {
public constructor(private prisma: PrismaService) {}
public constructor(private readonly prismaService: PrismaService) {}
public async validateImpersonationId(aId = '', aUserId: string) {
const accessObject = await this.prisma.access.findFirst({
const accessObject = await this.prismaService.access.findFirst({
where: { GranteeUser: { id: aUserId }, id: aId }
});

View File

@ -1,5 +1,10 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
import {
Account,
AssetClass,
Currency,
DataSource,
SymbolProfile
} from '@prisma/client';
import { OrderType } from '../../models/order-type';
@ -9,20 +14,13 @@ export const MarketState = {
open: 'open'
};
export const Type = {
Cash: 'Cash',
Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF',
Stock: 'Stock',
Unknown: UNKNOWN_KEY
};
export interface IOrder {
account: Account;
currency: Currency;
date: string;
fee: number;
id?: string;
isDraft: boolean;
quantity: number;
symbol: string;
symbolProfile: SymbolProfile;
@ -36,6 +34,7 @@ export interface IDataProviderHistoricalResponse {
}
export interface IDataProviderResponse {
assetClass?: AssetClass;
currency: Currency;
dataSource: DataSource;
exchange?: string;
@ -43,8 +42,7 @@ export interface IDataProviderResponse {
marketChangePercent?: number;
marketPrice: number;
marketState: MarketState;
name: string;
type?: Type;
name?: string;
url?: string;
}
@ -55,5 +53,3 @@ export interface IDataGatheringItem {
}
export type MarketState = typeof MarketState[keyof typeof MarketState];
export type Type = typeof Type[keyof typeof Type];

View File

@ -1,8 +1,9 @@
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Currency, DataSource } from '@prisma/client';
import { AssetClass, Currency, DataSource } from '@prisma/client';
export interface EnhancedSymbolProfile {
assetClass: AssetClass;
createdAt: Date;
currency: Currency | null;
dataSource: DataSource;

View File

@ -9,12 +9,12 @@ import { continents, countries } from 'countries-list';
@Injectable()
export class SymbolProfileService {
constructor(private prisma: PrismaService) {}
constructor(private readonly prismaService: PrismaService) {}
public async getSymbolProfiles(
symbols: string[]
): Promise<EnhancedSymbolProfile[]> {
return this.prisma.symbolProfile
return this.prismaService.symbolProfile
.findMany({
where: {
symbol: {

View File

@ -52,6 +52,41 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
},
{
path: 'portfolio',
loadChildren: () =>
import('./pages/portfolio/portfolio-page.module').then(
(m) => m.PortfolioPageModule
)
},
{
path: 'portfolio/allocations',
loadChildren: () =>
import('./pages/portfolio/allocations/allocations-page.module').then(
(m) => m.AllocationsPageModule
)
},
{
path: 'portfolio/analysis',
loadChildren: () =>
import('./pages/portfolio/analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{
path: 'portfolio/report',
loadChildren: () =>
import('./pages/portfolio/report/report-page.module').then(
(m) => m.ReportPageModule
)
},
{
path: 'portfolio/transactions',
loadChildren: () =>
import('./pages/portfolio/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{
path: 'pricing',
loadChildren: () =>
@ -80,32 +115,6 @@ const routes: Routes = [
(m) => m.LandingPageModule
)
},
{
path: 'tools',
loadChildren: () =>
import('./pages/tools/tools-page.module').then((m) => m.ToolsPageModule)
},
{
path: 'tools/analysis',
loadChildren: () =>
import('./pages/tools/analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{
path: 'tools/report',
loadChildren: () =>
import('./pages/tools/report/report-page.module').then(
(m) => m.ReportPageModule
)
},
{
path: 'transactions',
loadChildren: () =>
import('./pages/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{
path: 'webauthn',
loadChildren: () =>

View File

@ -2,7 +2,13 @@
<ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }}
<gf-symbol-icon
*ngIf="element.Platform?.url"
class="d-inline d-sm-none mr-1"
[tooltip]="element.Platform?.name"
[url]="element.Platform?.url"
></gf-symbol-icon>
<span>{{ element.name }}</span>
<span
*ngIf="element.isDefault"
class="d-lg-inline-block d-none text-muted"
@ -12,13 +18,20 @@
</ng-container>
<ng-container matColumnDef="platform">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Platform</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
>
Platform
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex">
<gf-symbol-icon
*ngIf="element.Platform?.url"
class="mr-1"
[tooltip]=""
[tooltip]="element.Platform?.name"
[url]="element.Platform?.url"
></gf-symbol-icon>
<span>{{ element.Platform?.name }}</span>
@ -27,17 +40,20 @@
</ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Transactions
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
<span class="d-block d-sm-none">#</span>
<span class="d-none d-sm-block" i18n>Transactions</span>
</th>
<td *matCellDef="let element" class="text-right" mat-cell>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>Balance</th>
<td *matCellDef="let element" class="text-right" mat-cell>
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>
Balance
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[currency]="element.currency"

View File

@ -22,27 +22,22 @@
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools',
'text-decoration-underline':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
'font-weight-bold': currentRoute === 'portfolio',
'text-decoration-underline': currentRoute === 'portfolio'
}"
[routerLink]="['/tools']"
>Tools</a
[routerLink]="['/portfolio']"
>Portfolio</a
>
<a
*ngIf="user?.settings?.viewMode !== 'DEFAULT'"
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'transactions',
'text-decoration-underline': currentRoute === 'transactions'
'font-weight-bold': currentRoute === 'portfolio',
'text-decoration-underline': currentRoute === 'portfolio'
}"
[routerLink]="['/transactions']"
[routerLink]="['/portfolio', 'transactions']"
>Transactions</a
>
<a
@ -166,22 +161,20 @@
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
'font-weight-bold': currentRoute === 'portfolio'
}"
[routerLink]="['/tools']"
>Tools</a
[routerLink]="['/portfolio']"
>Portfolio</a
>
<a
*ngIf="user?.settings?.viewMode !== 'DEFAULT'"
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === 'transactions'
'font-weight-bold': currentRoute === 'portfolio'
}"
[routerLink]="['/transactions']"
[routerLink]="['/portfolio', 'transactions']"
>Transactions</a
>
<a

View File

@ -73,7 +73,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
const lastItem = this.investments[this.investments.length - 1];
this.investments.push({
...lastItem,
date: addMonths(parseISO(lastItem.date), 3).toISOString()
date: addMonths(new Date(), 3).toISOString()
});
}
@ -154,8 +154,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
}
}
private isInFuture(aContext: any, aValue: any) {
return isAfter(new Date(aContext?.p0?.parsed?.x), new Date())
private isInFuture<T>(aContext: any, aValue: T) {
return isAfter(new Date(aContext?.p1?.parsed?.x), new Date())
? aValue
: undefined;
}

View File

@ -5,7 +5,7 @@
<a
class="align-items-center justify-content-center"
color="primary"
[routerLink]="['/transactions']"
[routerLink]="['/portfolio', 'transactions']"
mat-button
>
<span i18n>Time to add your first transaction.</span>

View File

@ -1,74 +0,0 @@
<div class="container p-0">
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Cash</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.cash"
></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>Buy</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.totalBuy"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Sell</div>
<div class="d-flex justify-content-end">
<span
*ngIf="overview?.totalSell || overview?.totalSell === 0"
class="mr-1"
>-</span
>
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.totalSell"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3">
<div class="d-flex flex-grow-1" i18n>Investment</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.committedFunds"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3">
<div class="d-flex flex-grow-1" i18n>
Fees for {{ overview?.ordersCount }} {overview?.ordersCount, plural, =1
{order} other {orders}}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.fees"
></gf-value>
</div>
</div>
</div>

View File

@ -1,28 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
import { PortfolioOverview } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
@Component({
selector: 'gf-portfolio-overview',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-overview.component.html',
styleUrls: ['./portfolio-overview.component.scss']
})
export class PortfolioOverviewComponent implements OnChanges, OnInit {
@Input() baseCurrency: Currency;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() overview: PortfolioOverview;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {}
}

View File

@ -1,54 +0,0 @@
<div class="container p-0">
<div class="row no-gutters">
<div class="flex-grow-1"></div>
<div *ngIf="isLoading" class="align-items-center d-flex">
<ngx-skeleton-loader
animation="pulse"
class="mb-2"
[theme]="{
height: '4rem',
width: '15rem'
}"
></ngx-skeleton-loader>
</div>
<div
[hidden]="isLoading"
class="display-4 font-weight-bold m-0 text-center value-container"
>
<span #value id="value"></span>
</div>
<div class="flex-grow-1 px-1">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '1.3rem',
width: '2.5rem'
}"
></ngx-skeleton-loader>
<div *ngIf="!isLoading">
{{ unit }}
</div>
</div>
</div>
<div *ngIf="showDetails" class="row">
<div class="d-flex col justify-content-end">
<gf-value
[colorizeSign]="true"
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value>
</div>
<div class="col">
<gf-value
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="
isLoading ? undefined : performance?.currentNetPerformancePercent
"
></gf-value>
</div>
</div>
</div>

View File

@ -1,9 +0,0 @@
:host {
display: block;
.value-container {
#value {
font-variant-numeric: tabular-nums;
}
}
}

View File

@ -1,65 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
OnChanges,
OnInit,
ViewChild
} from '@angular/core';
import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { CountUp } from 'countup.js';
import { isNumber } from 'lodash';
@Component({
selector: 'gf-portfolio-performance-summary',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-performance-summary.component.html',
styleUrls: ['./portfolio-performance-summary.component.scss']
})
export class PortfolioPerformanceSummaryComponent implements OnChanges, OnInit {
@Input() baseCurrency: Currency;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() performance: PortfolioPerformance;
@Input() showDetails: boolean;
@ViewChild('value') value: ElementRef;
public unit: string;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.isLoading) {
if (this.value?.nativeElement) {
this.value.nativeElement.innerHTML = '';
}
} else {
if (isNumber(this.performance?.currentValue)) {
this.unit = this.baseCurrency;
new CountUp('value', this.performance?.currentValue, {
decimalPlaces: 2,
duration: 1,
separator: `'`
}).start();
} else if (this.performance?.currentValue === null) {
this.unit = '%';
new CountUp(
'value',
this.performance?.currentNetPerformancePercent * 100,
{
decimalPlaces: 2,
duration: 0.75,
separator: `'`
}
).start();
}
}
}
}

View File

@ -1,14 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfValueModule } from '../value/value.module';
import { PortfolioPerformanceSummaryComponent } from './portfolio-performance-summary.component';
@NgModule({
declarations: [PortfolioPerformanceSummaryComponent],
exports: [PortfolioPerformanceSummaryComponent],
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
providers: []
})
export class GfPortfolioPerformanceSummaryModule {}

View File

@ -1,67 +1,54 @@
<div class="container p-0">
<div class="row px-3 py-2">
<div class="d-flex flex-grow-1" i18n>Value</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentValue"
></gf-value>
<div class="row no-gutters">
<div class="flex-grow-1"></div>
<div *ngIf="isLoading" class="align-items-center d-flex">
<ngx-skeleton-loader
animation="pulse"
class="mb-2"
[theme]="{
height: '4rem',
width: '15rem'
}"
></ngx-skeleton-loader>
</div>
<div
[hidden]="isLoading"
class="display-4 font-weight-bold m-0 text-center value-container"
>
<span #value id="value"></span>
</div>
<div class="flex-grow-1 px-1">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '1.3rem',
width: '2.5rem'
}"
></ngx-skeleton-loader>
<div *ngIf="!isLoading">
{{ unit }}
</div>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Absolute Performance</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<div *ngIf="showDetails" class="row">
<div class="d-flex col justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[currency]="baseCurrency"
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentGrossPerformance"
[value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Performance (TWR)</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<div class="col">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="
isLoading ? undefined : performance?.currentGrossPerformancePercent
isLoading ? undefined : performance?.currentNetPerformancePercent
"
></gf-value>
</div>
</div>
<!--
<div class="row px-3 py-2">
<div class="d-flex flex-grow-1" i18n>Net performance</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end mb-2"
position="end"
[colorizeSign]="true"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value>
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="
isLoading ? undefined : performance?.currentNetPerformancePercent
"
></gf-value>
</div>
</div>
-->
</div>

View File

@ -1,3 +1,9 @@
:host {
display: block;
.value-container {
#value {
font-variant-numeric: tabular-nums;
}
}
}

View File

@ -1,11 +1,16 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
OnInit
OnChanges,
OnInit,
ViewChild
} from '@angular/core';
import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { CountUp } from 'countup.js';
import { isNumber } from 'lodash';
@Component({
selector: 'gf-portfolio-performance',
@ -13,13 +18,48 @@ import { Currency } from '@prisma/client';
templateUrl: './portfolio-performance.component.html',
styleUrls: ['./portfolio-performance.component.scss']
})
export class PortfolioPerformanceComponent implements OnInit {
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() baseCurrency: Currency;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() performance: PortfolioPerformance;
@Input() showDetails: boolean;
@ViewChild('value') value: ElementRef;
public unit: string;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.isLoading) {
if (this.value?.nativeElement) {
this.value.nativeElement.innerHTML = '';
}
} else {
if (isNumber(this.performance?.currentValue)) {
this.unit = this.baseCurrency;
new CountUp('value', this.performance?.currentValue, {
decimalPlaces: 2,
duration: 1,
separator: `'`
}).start();
} else if (this.performance?.currentValue === null) {
this.unit = '%';
new CountUp(
'value',
this.performance?.currentNetPerformancePercent * 100,
{
decimalPlaces: 2,
duration: 0.75,
separator: `'`
}
).start();
}
}
}
}

View File

@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfValueModule } from '../value/value.module';
import { PortfolioPerformanceComponent } from './portfolio-performance.component';
@ -7,7 +8,7 @@ import { PortfolioPerformanceComponent } from './portfolio-performance.component
@NgModule({
declarations: [PortfolioPerformanceComponent],
exports: [PortfolioPerformanceComponent],
imports: [CommonModule, GfValueModule],
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
providers: []
})
export class GfPortfolioPerformanceModule {}

View File

@ -10,4 +10,4 @@ import { PortfolioProportionChartComponent } from './portfolio-proportion-chart.
imports: [CommonModule, NgxSkeletonLoaderModule],
providers: []
})
export class PortfolioProportionChartModule {}
export class GfPortfolioProportionChartModule {}

View File

@ -0,0 +1,134 @@
<div class="container p-0">
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Time in Market</div>
<div class="d-flex justify-content-end">
{{ timeInMarket }}
<gf-value class="justify-content-end" [value]="timeInMarket"></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3">
<div class="d-flex flex-grow-1" i18n>
Fees for {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1
{order} other {orders}}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.fees"
></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>Buy</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.totalBuy"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Sell</div>
<div class="d-flex justify-content-end">
<span *ngIf="summary?.totalSell || summary?.totalSell === 0" class="mr-1"
>-</span
>
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.totalSell"
></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>Investment</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.committedFunds"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Absolute Performance</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.currentGrossPerformance"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Performance (TWR)</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="
isLoading ? undefined : summary?.currentGrossPerformancePercent
"
></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>Value</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.currentValue"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Cash</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.cash"
></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>Net Worth</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.netWorth"
></gf-value>
</div>
</div>
</div>

View File

@ -0,0 +1,41 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
import { PortfolioSummary } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { formatDistanceToNow } from 'date-fns';
@Component({
selector: 'gf-portfolio-summary',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-summary.component.html',
styleUrls: ['./portfolio-summary.component.scss']
})
export class PortfolioSummaryComponent implements OnChanges, OnInit {
@Input() baseCurrency: Currency;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() summary: PortfolioSummary;
public timeInMarket: string;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.summary) {
if (this.summary.firstOrderDate) {
this.timeInMarket = formatDistanceToNow(this.summary.firstOrderDate);
} else {
this.timeInMarket = '-';
}
} else {
this.timeInMarket = undefined;
}
}
}

View File

@ -2,12 +2,12 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { GfValueModule } from '../value/value.module';
import { PortfolioOverviewComponent } from './portfolio-overview.component';
import { PortfolioSummaryComponent } from './portfolio-summary.component';
@NgModule({
declarations: [PortfolioOverviewComponent],
exports: [PortfolioOverviewComponent],
declarations: [PortfolioSummaryComponent],
exports: [PortfolioSummaryComponent],
imports: [CommonModule, GfValueModule],
providers: []
})
export class GfPortfolioOverviewModule {}
export class GfPortfolioSummaryModule {}

View File

@ -1,7 +1,7 @@
<gf-dialog-header
mat-dialog-title
[deviceType]="data.deviceType"
[title]="data.title"
[title]="data.title ?? data.symbol"
(closeButtonClicked)="onClose()"
></gf-dialog-header>

View File

@ -45,7 +45,7 @@
<gf-value
class="mr-3"
[colorizeSign]="true"
[currency]="position?.currency"
[currency]="baseCurrency"
[locale]="locale"
[value]="position?.grossPerformance"
></gf-value>

View File

@ -83,10 +83,10 @@
*matRowDef="let row; columns: displayedColumns"
mat-row
[ngClass]="{
'cursor-pointer': !this.ignoreTypes.includes(row.type)
'cursor-pointer': !this.ignoreAssetClasses.includes(row.assetClass)
}"
(click)="
!this.ignoreTypes.includes(row.type) &&
!this.ignoreAssetClasses.includes(row.assetClass) &&
onOpenPositionDialog({ symbol: row.symbol, title: row.name })
"
></tr>
@ -111,7 +111,7 @@
class="my-3 text-center"
>
<button i18n mat-stroked-button (click)="onShowAllPositions()">
Show all positions...
Show all...
</button>
</div>

View File

@ -14,9 +14,8 @@ 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 { Type } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Order as OrderModel } from '@prisma/client';
import { AssetClass, Order as OrderModel } from '@prisma/client';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -43,7 +42,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
public dataSource: MatTableDataSource<PortfolioPosition> =
new MatTableDataSource();
public displayedColumns = [];
public ignoreTypes = [Type.Cash];
public ignoreAssetClasses = [AssetClass.CASH.toString()];
public isLoading = true;
public pageSize = 7;
public routeQueryParams: Subscription;

View File

@ -5,10 +5,7 @@ import {
OnChanges,
OnInit
} from '@angular/core';
import {
MarketState,
Type
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { Position } from '@ghostfolio/common/interfaces';
@Component({
@ -28,8 +25,6 @@ export class PositionsComponent implements OnChanges, OnInit {
public positionsRest: Position[] = [];
public positionsWithPriority: Position[] = [];
private ignoreTypes = [Type.Cash];
public constructor() {}
public ngOnInit() {}
@ -46,10 +41,6 @@ export class PositionsComponent implements OnChanges, OnInit {
this.positionsWithPriority = [];
for (const portfolioPosition of this.positions) {
if (this.ignoreTypes.includes(portfolioPosition.type)) {
continue;
}
if (
portfolioPosition.marketState === MarketState.open ||
this.range !== '1d'

View File

@ -46,9 +46,7 @@
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
#
</th>
></th>
<td
*matCellDef="let element; let i = index"
class="d-none d-lg-table-cell px-1 text-right"
@ -96,10 +94,7 @@
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex align-items-center">
{{ element.symbol | gfSymbol }}
<span
*ngIf="isAfter(element.date, endOfToday)"
class="badge badge-secondary ml-1"
i18n
<span *ngIf="element.isDraft" class="badge badge-secondary ml-1" i18n
>Draft</span
>
</div>
@ -258,7 +253,12 @@
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
(click)="onOpenPositionDialog({ symbol: row.symbol, title: '' })"
(click)="
onOpenPositionDialog({
symbol: row.symbol,
title: row.SymbolProfile?.name
})
"
></tr>
</table>

View File

@ -85,14 +85,18 @@ export class ValueComponent implements OnChanges, OnInit {
});
} catch {}
}
} else if (isDate(new Date(this.value))) {
this.isDate = true;
this.isNumber = false;
} else {
try {
if (isDate(new Date(this.value))) {
this.isDate = true;
this.isNumber = false;
this.formattedDate = format(
new Date(<string>this.value),
DEFAULT_DATE_FORMAT
);
this.formattedDate = format(
new Date(<string>this.value),
DEFAULT_DATE_FORMAT
);
}
} catch {}
}
}
}

View File

@ -152,7 +152,7 @@ export class AdminPageComponent implements OnDestroy, OnInit {
} else if (lastDataGathering === 'IN_PROGRESS') {
this.dataGatheringInProgress = true;
} else {
this.lastDataGathering = '-';
this.lastDataGathering = 'Starting soon...';
}
this.transactionCount = transactionCount;

View File

@ -65,6 +65,14 @@
Finanzierung einer Wohnung, Ghostfolio bietet eine solide,
datengestützte Entscheidungshilfe.
</p>
<p class="my-5 text-center">
<img
alt="Ghostfol.io Screenshot"
src="./assets/images/screenshot.png"
style="max-width: 100%; width: 20rem"
title="Ghostfol.io Screenshot"
/>
</p>
<p>
Ich lege grossen Wert auf Datenschutz. Als
<a href="https://github.com/ghostfolio/ghostfolio"

View File

@ -49,8 +49,8 @@
<div class="col-md-10 offset-md-1">
<blockquote class="blockquote m-0">
<p class="mb-0">
Ghostfolio presents the big picture of assets to make the
best possible investment decisions.
Ghostfolio presents the big picture of your assets to make
the best possible investment decisions.
</p>
</blockquote>
</div>
@ -63,6 +63,14 @@
asset classes (stocks, cryptocurrencies, ETFs, etc.) or financing an
apartment, Ghostfolio offers solid, data-driven decision support.
</p>
<p class="my-5 text-center">
<img
alt="Ghostfol.io Screenshot"
src="./assets/images/screenshot.png"
style="max-width: 100%; width: 20rem"
title="Ghostfol.io Screenshot"
/>
</p>
<p>
As I value privacy, data protection is an integral part of
Ghostfolio. As

View File

@ -1,6 +1,4 @@
import { ViewportScroller } from '@angular/common';
import {
AfterViewInit,
ChangeDetectorRef,
Component,
ElementRef,
@ -10,6 +8,7 @@ import {
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatTabChangeEvent } from '@angular/material/tabs';
import { ActivatedRoute, Router } from '@angular/router';
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
import { PerformanceChartDialog } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.component';
@ -22,8 +21,8 @@ import {
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
PortfolioOverview,
PortfolioPerformance,
PortfolioSummary,
Position,
User
} from '@ghostfolio/common/interfaces';
@ -31,14 +30,14 @@ import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
import { first, takeUntil } from 'rxjs/operators';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-home-page',
templateUrl: './home-page.html',
styleUrls: ['./home-page.scss']
})
export class HomePageComponent implements AfterViewInit, OnDestroy, OnInit {
export class HomePageComponent implements OnDestroy, OnInit {
@HostBinding('class.with-create-account-container') get isDemo() {
return this.canCreateAccount;
}
@ -46,6 +45,7 @@ export class HomePageComponent implements AfterViewInit, OnDestroy, OnInit {
@ViewChild('positionsContainer') positionsContainer: ElementRef;
public canCreateAccount: boolean;
public currentTabIndex = 0;
public dateRange: DateRange;
public dateRangeOptions: ToggleOption[] = [
{ label: 'Today', value: '1d' },
@ -59,14 +59,14 @@ export class HomePageComponent implements AfterViewInit, OnDestroy, OnInit {
public hasImpersonationId: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToReadForeignPortfolio: boolean;
public hasPositions = false;
public hasPositions: boolean;
public historicalDataItems: LineChartItem[];
public isLoadingOverview = true;
public isLoadingPerformance = true;
public overview: PortfolioOverview;
public isLoadingSummary = true;
public performance: PortfolioPerformance;
public positions: Position[];
public routeQueryParams: Subscription;
public summary: PortfolioSummary;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -83,8 +83,7 @@ export class HomePageComponent implements AfterViewInit, OnDestroy, OnInit {
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService,
private userService: UserService,
private viewportScroller: ViewportScroller
private userService: UserService
) {
this.routeQueryParams = this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
@ -150,19 +149,15 @@ export class HomePageComponent implements AfterViewInit, OnDestroy, OnInit {
this.update();
}
public ngAfterViewInit(): void {
this.route.fragment
.pipe(first())
.subscribe((fragment) => this.viewportScroller.scrollToAnchor(fragment));
}
public onChangeDateRange(aDateRange: DateRange) {
this.dateRange = aDateRange;
this.settingsStorageService.setSetting(RANGE, this.dateRange);
this.update();
}
public onTabChanged() {
public onTabChanged(event: MatTabChangeEvent) {
this.currentTabIndex = event.index;
this.update();
}
@ -191,54 +186,55 @@ export class HomePageComponent implements AfterViewInit, OnDestroy, OnInit {
}
private update() {
this.hasPositions = undefined;
this.isLoadingOverview = true;
this.isLoadingPerformance = true;
this.positions = undefined;
if (this.currentTabIndex === 0) {
this.isLoadingPerformance = true;
this.dataService
.fetchChart({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((chartData) => {
this.historicalDataItems = chartData.map((chartDataItem) => {
return {
date: chartDataItem.date,
value: chartDataItem.value
};
this.dataService
.fetchChart({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((chartData) => {
this.historicalDataItems = chartData.map((chartDataItem) => {
return {
date: chartDataItem.date,
value: chartDataItem.value
};
});
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPortfolioPerformance({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.performance = response;
this.isLoadingPerformance = false;
this.dataService
.fetchPortfolioPerformance({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.performance = response;
this.isLoadingPerformance = false;
this.changeDetectorRef.markForCheck();
});
} else if (this.currentTabIndex === 1) {
this.dataService
.fetchPositions({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.positions = response.positions;
this.hasPositions = this.positions?.length > 0;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
});
} else if (this.currentTabIndex === 2) {
this.isLoadingSummary = true;
this.dataService
.fetchPortfolioOverview()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.overview = response;
this.isLoadingOverview = false;
this.dataService
.fetchPortfolioSummary()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.summary = response;
this.isLoadingSummary = false;
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPositions({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.positions = response.positions;
this.hasPositions = this.positions?.length > 0;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
});
}
this.changeDetectorRef.markForCheck();
}

View File

@ -3,7 +3,8 @@
class="position-absolute"
headerPosition="below"
mat-align-tabs="center"
(selectedTabChange)="onTabChanged()"
[disablePagination]="true"
(selectedTabChange)="onTabChanged($event)"
>
<mat-tab>
<ng-template mat-tab-label>
@ -54,14 +55,14 @@
</div>
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance-summary
<gf-portfolio-performance
class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale"
[performance]="performance"
[showDetails]="!hasImpersonationId || hasPermissionToReadForeignPortfolio"
></gf-portfolio-performance-summary>
></gf-portfolio-performance>
<div class="text-center">
<gf-toggle
[defaultValue]="dateRange"
@ -79,9 +80,9 @@
<ion-icon name="wallet-outline" size="large"></ion-icon>
</ng-template>
<div class="container justify-content-center pb-3 px-3 positions">
<h3 class="d-flex justify-content-center mb-3" i18n>Positions</h3>
<h3 class="d-flex justify-content-center mb-3" i18n>Holdings</h3>
<div class="row">
<div class="align-items-center col">
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
<div class="pb-2 text-center">
<gf-toggle
[defaultValue]="dateRange"
@ -118,33 +119,18 @@
</ng-template>
<div class="container pb-3 px-3 positions">
<div class="row">
<div class="col-xs-12 col-md-6 mb-3">
<mat-card class="h-100">
<mat-card-header>
<mat-card-title i18n>Performance</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-performance
[baseCurrency]="user?.settings?.baseCurrency"
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale"
[performance]="performance"
></gf-portfolio-performance>
</mat-card-content>
</mat-card>
</div>
<div class="col-xs-12 col-md-6">
<div class="col-xs-12 col-md-8 offset-md-2">
<mat-card class="h-100">
<mat-card-header>
<mat-card-title i18n>Summary</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-overview
<gf-portfolio-summary
[baseCurrency]="user?.settings?.baseCurrency"
[isLoading]="isLoadingOverview"
[isLoading]="isLoadingSummary"
[locale]="user?.settings?.locale"
[overview]="overview"
></gf-portfolio-overview>
[summary]="summary"
></gf-portfolio-summary>
</mat-card-content>
</mat-card>
</div>

View File

@ -7,9 +7,8 @@ import { RouterModule } from '@angular/router';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/client/components/no-transactions-info/no-transactions-info.module';
import { GfPerformanceChartDialogModule } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.module';
import { GfPortfolioOverviewModule } from '@ghostfolio/client/components/portfolio-overview/portfolio-overview.module';
import { GfPortfolioPerformanceSummaryModule } from '@ghostfolio/client/components/portfolio-performance-summary/portfolio-performance-summary.module';
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
import { GfPortfolioSummaryModule } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
@ -24,9 +23,8 @@ import { HomePageComponent } from './home-page.component';
GfLineChartModule,
GfNoTransactionsInfoModule,
GfPerformanceChartDialogModule,
GfPortfolioOverviewModule,
GfPortfolioPerformanceModule,
GfPortfolioPerformanceSummaryModule,
GfPortfolioSummaryModule,
GfPositionsModule,
GfToggleModule,
HomePageRoutingModule,

View File

@ -0,0 +1,15 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AllocationsPageComponent } from './allocations-page.component';
const routes: Routes = [
{ path: '', component: AllocationsPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AllocationsPageRoutingModule {}

View File

@ -4,22 +4,17 @@ 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 { UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
PortfolioItem,
PortfolioPosition,
User
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-analysis-page',
templateUrl: './analysis-page.html',
styleUrls: ['./analysis-page.scss']
selector: 'gf-allocations-page',
templateUrl: './allocations-page.html',
styleUrls: ['./allocations-page.scss']
})
export class AnalysisPageComponent implements OnDestroy, OnInit {
export class AllocationsPageComponent implements OnDestroy, OnInit {
public accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number };
};
@ -36,7 +31,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
{ label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' }
];
public investments: InvestmentItem[];
public portfolioPositions: { [symbol: string]: PortfolioPosition };
public positions: { [symbol: string]: any };
public positionsArray: PortfolioPosition[];
@ -71,15 +65,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!aId;
});
this.dataService
.fetchInvestments()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.investments = response;
this.changeDetectorRef.markForCheck();
});
this.dataService
.fetchPortfolioPositions({})
.pipe(takeUntil(this.unsubscribeSubject))
@ -131,9 +116,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
for (const [symbol, position] of Object.entries(aPortfolioPositions)) {
this.positions[symbol] = {
assetClass: position.assetClass,
currency: position.currency,
exchange: position.exchange,
type: position.type,
value:
aPeriod === 'original'
? position.allocationInvestment

View File

@ -1,41 +1,17 @@
<div class="container">
<div class="mb-5 row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
<div class="mb-4">
<h4 class="m-0" i18n>Positions</h4>
<gf-positions-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positionsArray"
></gf-positions-table>
</div>
<h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3>
<gf-positions-table
class="mb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positionsArray"
></gf-positions-table>
</div>
</div>
<div class="proportion-charts row">
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Type</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="type"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
[locale]="user?.settings?.locale"
[positions]="positions"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
@ -61,7 +37,7 @@
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Currency</mat-card-title>
<mat-card-title i18n>By Asset Class</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
@ -71,7 +47,7 @@
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="currency"
key="assetClass"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
[locale]="user?.settings?.locale"
@ -83,7 +59,7 @@
<div class="col-md-6">
<mat-card class="mb-3">
<mat-card-header class="w-100">
<mat-card-title i18n>By Exchange</mat-card-title>
<mat-card-title i18n>By Currency</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
@ -93,7 +69,7 @@
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
key="exchange"
key="currency"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
[locale]="user?.settings?.locale"
@ -192,20 +168,4 @@
</mat-card>
</div>
</div>
<div class="investment-chart row">
<div class="col-lg">
<mat-card class="mb-3">
<mat-card-header>
<mat-card-title class="align-items-center d-flex" i18n
>Timeline</mat-card-title
>
</mat-card-header>
<mat-card-content>
<gf-investment-chart
[investments]='investments'
></gf-investment-chart>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@ -0,0 +1,27 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { GfPortfolioProportionChartModule } from '@ghostfolio/client/components/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { AllocationsPageRoutingModule } from './allocations-page-routing.module';
import { AllocationsPageComponent } from './allocations-page.component';
@NgModule({
declarations: [AllocationsPageComponent],
exports: [],
imports: [
AllocationsPageRoutingModule,
CommonModule,
GfPortfolioProportionChartModule,
GfPositionsTableModule,
GfToggleModule,
GfWorldMapChartModule,
MatCardModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AllocationsPageModule {}

View File

@ -1,16 +1,4 @@
:host {
.investment-chart {
.mat-card {
.mat-card-content {
aspect-ratio: 16 / 9;
gf-investment-chart {
height: 100%;
}
}
}
}
.proportion-charts {
.mat-card {
.mat-card-content {

View File

@ -0,0 +1,92 @@
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 { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-analysis-page',
templateUrl: './analysis-page.html',
styleUrls: ['./analysis-page.scss']
})
export class AnalysisPageComponent implements OnDestroy, OnInit {
public accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { value: number };
};
public continents: {
[code: string]: { name: string; value: number };
};
public countries: {
[code: string]: { name: string; value: number };
};
public deviceType: string;
public hasImpersonationId: boolean;
public period = 'current';
public periodOptions: ToggleOption[] = [
{ label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' }
];
public investments: InvestmentItem[];
public portfolioPositions: { [symbol: string]: PortfolioPosition };
public positions: { [symbol: string]: any };
public sectors: {
[name: string]: { name: string; value: number };
};
public user: User;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private impersonationStorageService: ImpersonationStorageService,
private userService: UserService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
this.dataService
.fetchInvestments()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.investments = response;
this.changeDetectorRef.markForCheck();
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,20 @@
<div class="container">
<div class="investment-chart row">
<div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
<mat-card class="mb-3">
<mat-card-header>
<mat-card-title class="align-items-center d-flex" i18n
>Timeline</mat-card-title
>
</mat-card-header>
<mat-card-content>
<gf-investment-chart
class="h-100"
[investments]="investments"
></gf-investment-chart>
</mat-card-content>
</mat-card>
</div>
</div>
</div>

View File

@ -2,10 +2,6 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
import { PortfolioProportionChartModule } from '@ghostfolio/client/components/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { AnalysisPageRoutingModule } from './analysis-page-routing.module';
import { AnalysisPageComponent } from './analysis-page.component';
@ -17,11 +13,7 @@ import { AnalysisPageComponent } from './analysis-page.component';
AnalysisPageRoutingModule,
CommonModule,
GfInvestmentChartModule,
GfPositionsTableModule,
GfToggleModule,
GfWorldMapChartModule,
MatCardModule,
PortfolioProportionChartModule
MatCardModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -0,0 +1,9 @@
:host {
.investment-chart {
.mat-card {
.mat-card-content {
aspect-ratio: 16 / 9;
}
}
}
}

View File

@ -2,14 +2,14 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ToolsPageComponent } from './tools-page.component';
import { PortfolioPageComponent } from './portfolio-page.component';
const routes: Routes = [
{ path: '', component: ToolsPageComponent, canActivate: [AuthGuard] }
{ path: '', component: PortfolioPageComponent, canActivate: [AuthGuard] }
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ToolsPageRoutingModule {}
export class PortfolioPageRoutingModule {}

View File

@ -0,0 +1,44 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@Component({
selector: 'gf-portfolio-page',
templateUrl: './portfolio-page.html',
styleUrls: ['./portfolio-page.scss']
})
export class PortfolioPageComponent implements OnDestroy, OnInit {
public user: User;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private userService: UserService
) {}
/**
* Initializes the controller
*/
public ngOnInit() {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.changeDetectorRef.markForCheck();
}
});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -0,0 +1,83 @@
<div class="container">
<h3 class="d-flex justify-content-center mb-3" i18n>Portfolio</h3>
<div class="row">
<div class="col-xs-12 col-md-6">
<mat-card class="mb-3">
<h4 i18n>Transactions</h4>
<p class="mb-0">Manage your transactions.</p>
<p class="text-right">
<a
color="primary"
i18n
mat-button
[routerLink]="['/portfolio', 'transactions']"
>
Open Transactions →
</a>
</p>
</mat-card>
</div>
<div
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
class="col-xs-12 col-md-6"
>
<mat-card class="mb-3">
<h4 i18n>Allocations</h4>
<p class="mb-0">Check the allocations of your portfolio.</p>
<p class="text-right">
<a
color="primary"
i18n
mat-button
[routerLink]="['/portfolio', 'allocations']"
>
Open Allocations →
</a>
</p>
</mat-card>
</div>
</div>
<div class="row">
<div
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
class="col-xs-12 col-md-6"
>
<mat-card class="mb-3">
<h4 i18n>Analysis</h4>
<p class="mb-0">Ghostfolio Analysis visualizes your portfolio.</p>
<p class="text-right">
<a
color="primary"
i18n
mat-button
[routerLink]="['/portfolio', 'analysis']"
>
Open Analysis →
</a>
</p>
</mat-card>
</div>
<div
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
class="col-xs-12 col-md-6"
>
<mat-card class="mb-3">
<h4 i18n>X-ray</h4>
<p class="mb-0">
Ghostfolio X-ray uses static analysis to identify potential issues and
risks in your portfolio.
</p>
<p class="text-right">
<a
color="primary"
i18n
mat-button
[routerLink]="['/portfolio', 'report']"
>
Open X-ray →
</a>
</p>
</mat-card>
</div>
</div>
</div>

View File

@ -4,20 +4,20 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { ToolsPageRoutingModule } from './tools-page-routing.module';
import { ToolsPageComponent } from './tools-page.component';
import { PortfolioPageRoutingModule } from './portfolio-page-routing.module';
import { PortfolioPageComponent } from './portfolio-page.component';
@NgModule({
declarations: [ToolsPageComponent],
declarations: [PortfolioPageComponent],
exports: [],
imports: [
CommonModule,
MatButtonModule,
MatCardModule,
RouterModule,
ToolsPageRoutingModule
PortfolioPageRoutingModule,
RouterModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ToolsPageModule {}
export class PortfolioPageModule {}

View File

@ -9,6 +9,7 @@ import { FormControl, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { Currency } from '@prisma/client';
import { Observable, Subject } from 'rxjs';
import {
@ -19,7 +20,6 @@ import {
takeUntil
} from 'rxjs/operators';
import { DataService } from '../../../services/data.service';
import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces';
@Component({

View File

@ -166,7 +166,7 @@
color="primary"
i18n
mat-flat-button
[disabled]="!(addTransactionForm.form.valid && data.transaction.symbol)"
[disabled]="!(addTransactionForm.form.valid && data.transaction.currency && data.transaction.symbol)"
[mat-dialog-close]="data"
>
Save

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