Compare commits
27 Commits
Author | SHA1 | Date | |
---|---|---|---|
a4d049e53d | |||
f9c4408126 | |||
d046f1d498 | |||
ad96d6e53e | |||
747e5b63fa | |||
b1187cf880 | |||
ba9e6eab58 | |||
01feead017 | |||
6a0cfb8f77 | |||
6386786ac0 | |||
d3be6577c8 | |||
73a967a7e5 | |||
836ff6ec13 | |||
c5bb3023d3 | |||
695c378b48 | |||
fe975945d1 | |||
d8782b0d4c | |||
e14f08a8fb | |||
72c065a59d | |||
98dac4052a | |||
2083d28d02 | |||
addd5c36d9 | |||
aad8f77093 | |||
a904208d06 | |||
2733b78044 | |||
b43b515df1 | |||
70e14b4d3c |
66
CHANGELOG.md
66
CHANGELOG.md
@ -5,6 +5,72 @@ 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.45.0 - 04.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a link below the holdings to manage the transactions
|
||||
- Added the allocation chart by symbol
|
||||
|
||||
### Changed
|
||||
|
||||
- Restructured the allocations page
|
||||
- Upgraded `angular` from version `12.0.4` to `12.2.4`
|
||||
- Upgraded `@angular/cdk` and `@angular/material` from version `12.0.6` to `12.2.4`
|
||||
- Upgraded `Nx` from version `12.5.4` to `12.8.0`
|
||||
- Upgraded `prisma` from version `2.24.1` to `2.30.2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the value formatting for integers (transactions count)
|
||||
|
||||
## 1.44.0 - 30.08.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the sub classification of assets by cash
|
||||
- Upgraded `svgmap` from version `2.1.1` to `2.6.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Filtered out positions without any quantity in the positions table
|
||||
- Improved the symbol lookup: allow saving with valid symbol in create or edit transaction dialog
|
||||
|
||||
## 1.43.0 - 24.08.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the data management of symbol profile data by countries (automated for stocks)
|
||||
- Added a fallback for initially loading currencies if historical data is not yet available
|
||||
|
||||
## 1.42.0 - 22.08.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the subscription type to the users table of the admin control panel
|
||||
- Introduced the sub classification of assets
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:push`)
|
||||
|
||||
## 1.41.0 - 21.08.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a link to the system status page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the wording for the _Restricted View_: _Presenter View_
|
||||
- Improved the styling of the tables
|
||||
- Ignored cash assets in the allocation chart by sector, continent and country
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the allocation chart by account (wrong calculation)
|
||||
- Fixed an issue in the allocation chart by account (missing cash accounts)
|
||||
|
||||
## 1.40.0 - 19.08.2021
|
||||
|
||||
### Changed
|
||||
|
@ -62,7 +62,7 @@ Ghostfolio is for you if you are...
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`)
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Dark Mode
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
@ -14,9 +15,11 @@ import { AdminService } from './admin.service';
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
PrismaModule
|
||||
PrismaModule,
|
||||
SubscriptionModule
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService]
|
||||
providers: [AdminService],
|
||||
exports: [AdminService]
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
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';
|
||||
@ -9,9 +11,11 @@ import { differenceInDays } from 'date-fns';
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
|
||||
public async get(): Promise<AdminData> {
|
||||
@ -107,7 +111,8 @@ export class AdminService {
|
||||
}
|
||||
},
|
||||
createdAt: true,
|
||||
id: true
|
||||
id: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 30,
|
||||
where: {
|
||||
@ -118,16 +123,23 @@ export class AdminService {
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, alias, Analytics, createdAt, id }) => {
|
||||
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics.activityCount / daysSinceRegistration;
|
||||
|
||||
const subscription = this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
)
|
||||
? this.subscriptionService.getSubscription(Subscription)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
alias,
|
||||
createdAt,
|
||||
engagement,
|
||||
id,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
lastActivity: Analytics.updatedAt,
|
||||
transactionCount: _count.Order || 0
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
@ -17,7 +18,8 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
})
|
||||
}),
|
||||
SubscriptionModule
|
||||
],
|
||||
providers: [
|
||||
AuthDeviceService,
|
||||
|
@ -5,8 +5,8 @@ import {
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
PortfolioSummary
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -124,13 +124,11 @@ export class PortfolioController {
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
||||
const { details, hasErrors } = await this.portfolioService.getDetails(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
): Promise<PortfolioDetails> {
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(impersonationId, range);
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(details)) {
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
@ -138,13 +136,13 @@ export class PortfolioController {
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const totalInvestment = Object.values(details)
|
||||
const totalInvestment = Object.values(holdings)
|
||||
.map((portfolioPosition) => {
|
||||
return portfolioPosition.investment;
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
const totalValue = Object.values(details)
|
||||
const totalValue = Object.values(holdings)
|
||||
.map((portfolioPosition) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
@ -154,24 +152,21 @@ export class PortfolioController {
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(details)) {
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
portfolioPosition.grossPerformance = null;
|
||||
portfolioPosition.investment =
|
||||
portfolioPosition.investment / totalInvestment;
|
||||
|
||||
for (const [account, { current, original }] of Object.entries(
|
||||
portfolioPosition.accounts
|
||||
)) {
|
||||
portfolioPosition.accounts[account].current = current / totalValue;
|
||||
portfolioPosition.accounts[account].original =
|
||||
original / totalInvestment;
|
||||
}
|
||||
|
||||
portfolioPosition.quantity = null;
|
||||
}
|
||||
|
||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||
accounts[name].current = current / totalValue;
|
||||
accounts[name].original = original / totalInvestment;
|
||||
}
|
||||
}
|
||||
|
||||
return <any>res.json(details);
|
||||
return <any>res.json({ accounts, holdings });
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
|
@ -24,6 +24,7 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.se
|
||||
import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
@ -154,10 +155,7 @@ export class PortfolioService {
|
||||
public async getDetails(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{
|
||||
details: { [symbol: string]: PortfolioPosition };
|
||||
hasErrors: boolean;
|
||||
}> {
|
||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
@ -171,7 +169,7 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return { details: {}, hasErrors: false };
|
||||
return { accounts: {}, holdings: {}, hasErrors: false };
|
||||
}
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
@ -187,7 +185,7 @@ export class PortfolioService {
|
||||
userCurrency
|
||||
);
|
||||
|
||||
const details: { [symbol: string]: PortfolioPosition } = {};
|
||||
const holdings: PortfolioDetails['holdings'] = {};
|
||||
const totalInvestment = currentPositions.totalInvestment.plus(
|
||||
cashDetails.balance
|
||||
);
|
||||
@ -211,17 +209,21 @@ export class PortfolioService {
|
||||
for (const position of currentPositions.positions) {
|
||||
portfolioItemsNow[position.symbol] = position;
|
||||
}
|
||||
const accounts = this.getAccounts(orders, portfolioItemsNow, userCurrency);
|
||||
|
||||
for (const item of currentPositions.positions) {
|
||||
if (item.quantity.lte(0)) {
|
||||
// Ignore positions without any quantity
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = item.quantity.mul(item.marketPrice);
|
||||
const symbolProfile = symbolProfileMap[item.symbol];
|
||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||
details[item.symbol] = {
|
||||
accounts,
|
||||
holdings[item.symbol] = {
|
||||
allocationCurrent: value.div(totalValue).toNumber(),
|
||||
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
countries: symbolProfile.countries,
|
||||
currency: item.currency,
|
||||
exchange: dataProviderResponse.exchange,
|
||||
@ -241,13 +243,20 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
// TODO: Add a cash position for each currency
|
||||
details[ghostfolioCashSymbol] = await this.getCashPosition({
|
||||
holdings[ghostfolioCashSymbol] = await this.getCashPosition({
|
||||
cashDetails,
|
||||
investment: totalInvestment,
|
||||
value: totalValue
|
||||
});
|
||||
|
||||
return { details, hasErrors: currentPositions.hasErrors };
|
||||
const accounts = await this.getAccounts(
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId
|
||||
);
|
||||
|
||||
return { accounts, holdings, hasErrors: currentPositions.hasErrors };
|
||||
}
|
||||
|
||||
public async getPosition(
|
||||
@ -604,7 +613,12 @@ export class PortfolioService {
|
||||
for (const position of currentPositions.positions) {
|
||||
portfolioItemsNow[position.symbol] = position;
|
||||
}
|
||||
const accounts = this.getAccounts(orders, portfolioItemsNow, baseCurrency);
|
||||
const accounts = await this.getAccounts(
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
baseCurrency,
|
||||
userId
|
||||
);
|
||||
return {
|
||||
rules: {
|
||||
accountClusterRisk: await this.rulesService.evaluate(
|
||||
@ -704,21 +718,13 @@ export class PortfolioService {
|
||||
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,
|
||||
assetSubClass: AssetClass.CASH,
|
||||
countries: [],
|
||||
currency: Currency.CHF,
|
||||
grossPerformance: 0,
|
||||
@ -797,41 +803,67 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
private getAccounts(
|
||||
private async getAccounts(
|
||||
orders: OrderWithAccount[],
|
||||
portfolioItemsNow: { [p: string]: TimelinePosition },
|
||||
userCurrency
|
||||
userCurrency: Currency,
|
||||
userId: string
|
||||
) {
|
||||
const accounts: PortfolioPosition['accounts'] = {};
|
||||
for (const order of orders) {
|
||||
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
|
||||
order.currency,
|
||||
userCurrency
|
||||
);
|
||||
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * order.unitPrice,
|
||||
order.currency,
|
||||
userCurrency
|
||||
);
|
||||
const accounts: PortfolioDetails['accounts'] = {};
|
||||
|
||||
if (order.type === 'SELL') {
|
||||
currentValueOfSymbol *= -1;
|
||||
originalValueOfSymbol *= -1;
|
||||
const currentAccounts = await this.accountService.getAccounts(userId);
|
||||
|
||||
for (const account of currentAccounts) {
|
||||
const ordersByAccount = orders.filter(({ accountId }) => {
|
||||
return accountId === account.id;
|
||||
});
|
||||
|
||||
if (ordersByAccount.length <= 0) {
|
||||
// Add account without orders
|
||||
const balance = this.exchangeRateDataService.toCurrency(
|
||||
account.balance,
|
||||
account.currency,
|
||||
userCurrency
|
||||
);
|
||||
accounts[account.name] = {
|
||||
current: balance,
|
||||
original: balance
|
||||
};
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].current +=
|
||||
currentValueOfSymbol;
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].original +=
|
||||
originalValueOfSymbol;
|
||||
} else {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY] = {
|
||||
current: currentValueOfSymbol,
|
||||
original: originalValueOfSymbol
|
||||
};
|
||||
for (const order of ordersByAccount) {
|
||||
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
|
||||
order.currency,
|
||||
userCurrency
|
||||
);
|
||||
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
||||
order.quantity * order.unitPrice,
|
||||
order.currency,
|
||||
userCurrency
|
||||
);
|
||||
|
||||
if (order.type === 'SELL') {
|
||||
currentValueOfSymbol *= -1;
|
||||
originalValueOfSymbol *= -1;
|
||||
}
|
||||
|
||||
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].current +=
|
||||
currentValueOfSymbol;
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].original +=
|
||||
originalValueOfSymbol;
|
||||
} else {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY] = {
|
||||
current: currentValueOfSymbol,
|
||||
original: originalValueOfSymbol
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return accounts;
|
||||
}
|
||||
|
||||
|
@ -8,6 +8,7 @@ import { SubscriptionService } from './subscription.service';
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [SubscriptionController],
|
||||
providers: [ConfigurationService, PrismaService, SubscriptionService]
|
||||
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
||||
exports: [SubscriptionService]
|
||||
})
|
||||
export class SubscriptionModule {}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { addDays } from 'date-fns';
|
||||
import { Subscription } from '@prisma/client';
|
||||
import { addDays, isBefore } from 'date-fns';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
@Injectable()
|
||||
@ -86,4 +88,23 @@ export class SubscriptionService {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
public getSubscription(aSubscriptions: Subscription[]) {
|
||||
if (aSubscriptions.length > 0) {
|
||||
const latestSubscription = aSubscriptions.reduce((a, b) => {
|
||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||
});
|
||||
|
||||
return {
|
||||
expiresAt: latestSubscription.expiresAt,
|
||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
||||
? SubscriptionType.Premium
|
||||
: SubscriptionType.Basic
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: SubscriptionType.Basic
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ import {
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
@ -48,6 +49,15 @@ export class SymbolController {
|
||||
@Get(':symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> {
|
||||
return this.symbolService.get(symbol);
|
||||
const result = await this.symbolService.get(symbol);
|
||||
|
||||
if (!result || isEmpty(result)) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
@ -15,13 +15,17 @@ export class SymbolService {
|
||||
|
||||
public async get(aSymbol: string): Promise<SymbolItem> {
|
||||
const response = await this.dataProviderService.get([aSymbol]);
|
||||
const { currency, dataSource, marketPrice } = response[aSymbol];
|
||||
const { currency, dataSource, marketPrice } = response[aSymbol] ?? {};
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
marketPrice,
|
||||
currency: <Currency>(<unknown>currency)
|
||||
};
|
||||
if (currency && dataSource && marketPrice) {
|
||||
return {
|
||||
dataSource,
|
||||
marketPrice,
|
||||
currency: <Currency>(<unknown>currency)
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -11,7 +12,8 @@ import { UserService } from './user.service';
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
})
|
||||
}),
|
||||
SubscriptionModule
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [ConfigurationService, PrismaService, UserService],
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { locale } from '@ghostfolio/common/config';
|
||||
@ -6,7 +7,6 @@ import { getPermissions, permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||
import { isBefore } from 'date-fns';
|
||||
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
import { UserSettings } from './interfaces/user-settings.interface';
|
||||
@ -19,7 +19,8 @@ export class UserService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
|
||||
public async getUser({
|
||||
@ -98,24 +99,9 @@ export class UserService {
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (userFromDatabase?.Subscription?.length > 0) {
|
||||
const latestSubscription = userFromDatabase.Subscription.reduce(
|
||||
(a, b) => {
|
||||
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
|
||||
}
|
||||
);
|
||||
|
||||
user.subscription = {
|
||||
expiresAt: latestSubscription.expiresAt,
|
||||
type: isBefore(new Date(), latestSubscription.expiresAt)
|
||||
? SubscriptionType.Premium
|
||||
: SubscriptionType.Basic
|
||||
};
|
||||
} else {
|
||||
user.subscription = {
|
||||
type: SubscriptionType.Basic
|
||||
};
|
||||
}
|
||||
user.subscription = this.subscriptionService.getSubscription(
|
||||
userFromDatabase?.Subscription
|
||||
);
|
||||
|
||||
if (user.subscription.type === SubscriptionType.Basic) {
|
||||
user.permissions = user.permissions.filter((permission) => {
|
||||
|
@ -1,16 +1,17 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: {
|
||||
[account: string]: { current: number; original: number };
|
||||
}
|
||||
private accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Current Investment'
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
@ -8,9 +11,7 @@ import { Rule } from '../../rule';
|
||||
export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: {
|
||||
[account: string]: { current: number; original: number };
|
||||
}
|
||||
private accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Initial Investment'
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { PortfolioDetails } from '@ghostfolio/common/interfaces';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
@ -7,9 +8,7 @@ import { Rule } from '../../rule';
|
||||
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: {
|
||||
[account: string]: { current: number; original: number };
|
||||
}
|
||||
private accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Single Account'
|
||||
|
@ -38,7 +38,7 @@ export class DataGatheringService {
|
||||
|
||||
if (isDataGatheringNeeded) {
|
||||
console.log('7d data gathering has been started.');
|
||||
console.time('7d-data-gathering');
|
||||
console.time('data-gathering-7d');
|
||||
|
||||
await this.prismaService.property.create({
|
||||
data: {
|
||||
@ -71,7 +71,7 @@ export class DataGatheringService {
|
||||
});
|
||||
|
||||
console.log('7d data gathering has been completed.');
|
||||
console.timeEnd('7d-data-gathering');
|
||||
console.timeEnd('data-gathering-7d');
|
||||
}
|
||||
}
|
||||
|
||||
@ -82,7 +82,7 @@ export class DataGatheringService {
|
||||
|
||||
if (!isDataGatheringLocked) {
|
||||
console.log('Max data gathering has been started.');
|
||||
console.time('max-data-gathering');
|
||||
console.time('data-gathering-max');
|
||||
|
||||
await this.prismaService.property.create({
|
||||
data: {
|
||||
@ -115,13 +115,13 @@ export class DataGatheringService {
|
||||
});
|
||||
|
||||
console.log('Max data gathering has been completed.');
|
||||
console.timeEnd('max-data-gathering');
|
||||
console.timeEnd('data-gathering-max');
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherProfileData(aSymbols?: string[]) {
|
||||
console.log('Profile data gathering has been started.');
|
||||
console.time('profile-data-gathering');
|
||||
console.time('data-gathering-profile');
|
||||
|
||||
let symbols = aSymbols;
|
||||
|
||||
@ -136,12 +136,14 @@ export class DataGatheringService {
|
||||
|
||||
for (const [
|
||||
symbol,
|
||||
{ assetClass, currency, dataSource, name }
|
||||
{ assetClass, assetSubClass, countries, currency, dataSource, name }
|
||||
] of Object.entries(currentData)) {
|
||||
try {
|
||||
await this.prismaService.symbolProfile.upsert({
|
||||
create: {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
@ -149,6 +151,8 @@ export class DataGatheringService {
|
||||
},
|
||||
update: {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
countries,
|
||||
currency,
|
||||
name
|
||||
},
|
||||
@ -165,7 +169,7 @@ export class DataGatheringService {
|
||||
}
|
||||
|
||||
console.log('Profile data gathering has been completed.');
|
||||
console.timeEnd('profile-data-gathering');
|
||||
console.timeEnd('data-gathering-profile');
|
||||
}
|
||||
|
||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||
|
@ -25,6 +25,7 @@ export interface IYahooFinancePrice {
|
||||
}
|
||||
|
||||
export interface IYahooFinanceSummaryProfile {
|
||||
country?: string;
|
||||
industry?: string;
|
||||
sector?: string;
|
||||
website?: string;
|
||||
|
@ -8,9 +8,15 @@ import {
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AssetClass, Currency, DataSource } from '@prisma/client';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
Currency,
|
||||
DataSource
|
||||
} from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import Big from 'big.js';
|
||||
import { countries } from 'countries-list';
|
||||
import { format } from 'date-fns';
|
||||
import * as yahooFinance from 'yahoo-finance';
|
||||
|
||||
@ -22,6 +28,7 @@ import {
|
||||
} from '../../interfaces/interfaces';
|
||||
import {
|
||||
IYahooFinanceHistoricalResponse,
|
||||
IYahooFinancePrice,
|
||||
IYahooFinanceQuoteResponse
|
||||
} from './interfaces/interfaces';
|
||||
|
||||
@ -60,8 +67,11 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
// Convert symbols back
|
||||
const symbol = convertFromYahooSymbol(yahooSymbol);
|
||||
|
||||
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
|
||||
|
||||
response[symbol] = {
|
||||
assetClass: this.parseAssetClass(value.price?.quoteType),
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency: parseCurrency(value.price?.currency),
|
||||
dataSource: DataSource.YAHOO,
|
||||
exchange: this.parseExchange(value.price?.exchangeName),
|
||||
@ -83,6 +93,23 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
.toNumber();
|
||||
}
|
||||
|
||||
// Add country if stock and available
|
||||
if (
|
||||
assetSubClass === AssetSubClass.STOCK &&
|
||||
value.summaryProfile?.country
|
||||
) {
|
||||
try {
|
||||
const [code] = Object.entries(countries).find(([, country]) => {
|
||||
return country.name === value.summaryProfile?.country;
|
||||
});
|
||||
|
||||
if (code) {
|
||||
response[symbol].countries = [{ code, weight: 1 }];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Add url if available
|
||||
const url = value.summaryProfile?.website;
|
||||
if (url) {
|
||||
response[symbol].url = url;
|
||||
@ -229,20 +256,29 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return aSymbol;
|
||||
}
|
||||
|
||||
private parseAssetClass(aString: string): AssetClass {
|
||||
private parseAssetClass(aPrice: IYahooFinancePrice): {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
} {
|
||||
let assetClass: AssetClass;
|
||||
let assetSubClass: AssetSubClass;
|
||||
|
||||
switch (aString?.toLowerCase()) {
|
||||
switch (aPrice?.quoteType?.toLowerCase()) {
|
||||
case 'cryptocurrency':
|
||||
assetClass = AssetClass.CASH;
|
||||
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
|
||||
break;
|
||||
case 'equity':
|
||||
assetClass = AssetClass.EQUITY;
|
||||
assetSubClass = AssetSubClass.STOCK;
|
||||
break;
|
||||
case 'etf':
|
||||
assetClass = AssetClass.EQUITY;
|
||||
assetSubClass = AssetSubClass.ETF;
|
||||
break;
|
||||
}
|
||||
|
||||
return assetClass;
|
||||
return { assetClass, assetSubClass };
|
||||
}
|
||||
|
||||
private parseExchange(aString: string): string {
|
||||
|
@ -3,7 +3,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 { isEmpty, isNumber } from 'lodash';
|
||||
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
|
||||
@ -35,6 +35,24 @@ export class ExchangeRateDataService {
|
||||
getYesterday()
|
||||
);
|
||||
|
||||
if (isEmpty(result)) {
|
||||
// Load currencies directly from data provider as a fallback
|
||||
// if historical data is not yet available
|
||||
const historicalData = await this.dataProviderService.get(
|
||||
this.currencyPairs.map((currencyPair) => {
|
||||
return currencyPair;
|
||||
})
|
||||
);
|
||||
|
||||
Object.keys(historicalData).forEach((key) => {
|
||||
result[key] = {
|
||||
[format(getYesterday(), DATE_FORMAT)]: {
|
||||
marketPrice: historicalData[key].marketPrice
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const resultExtended = result;
|
||||
|
||||
Object.keys(result).forEach((pair) => {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import {
|
||||
Account,
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
Currency,
|
||||
DataSource,
|
||||
SymbolProfile
|
||||
@ -35,6 +36,8 @@ export interface IDataProviderHistoricalResponse {
|
||||
|
||||
export interface IDataProviderResponse {
|
||||
assetClass?: AssetClass;
|
||||
assetSubClass?: AssetSubClass;
|
||||
countries?: { code: string; weight: number }[];
|
||||
currency: Currency;
|
||||
dataSource: DataSource;
|
||||
exchange?: string;
|
||||
|
@ -1,9 +1,15 @@
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { AssetClass, Currency, DataSource } from '@prisma/client';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
Currency,
|
||||
DataSource
|
||||
} from '@prisma/client';
|
||||
|
||||
export interface EnhancedSymbolProfile {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
createdAt: Date;
|
||||
currency: Currency | null;
|
||||
dataSource: DataSource;
|
||||
|
@ -16,6 +16,7 @@ import { LinearScale } from 'chart.js';
|
||||
import { ArcElement } from 'chart.js';
|
||||
import { DoughnutController } from 'chart.js';
|
||||
import { Chart } from 'chart.js';
|
||||
import * as Color from 'color';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-portfolio-proportion-chart',
|
||||
@ -28,7 +29,7 @@ export class PortfolioProportionChartComponent
|
||||
{
|
||||
@Input() baseCurrency: Currency;
|
||||
@Input() isInPercent: boolean;
|
||||
@Input() key: string;
|
||||
@Input() keys: string[];
|
||||
@Input() locale: string;
|
||||
@Input() maxItems?: number;
|
||||
@Input() positions: {
|
||||
@ -65,24 +66,54 @@ export class PortfolioProportionChartComponent
|
||||
private initialize() {
|
||||
this.isLoading = true;
|
||||
const chartData: {
|
||||
[symbol: string]: { color?: string; value: number };
|
||||
[symbol: string]: {
|
||||
color?: string;
|
||||
subCategory: { [symbol: string]: { value: number } };
|
||||
value: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
Object.keys(this.positions).forEach((symbol) => {
|
||||
if (this.positions[symbol][this.key]) {
|
||||
if (chartData[this.positions[symbol][this.key]]) {
|
||||
chartData[this.positions[symbol][this.key]].value +=
|
||||
if (this.positions[symbol][this.keys[0]]) {
|
||||
if (chartData[this.positions[symbol][this.keys[0]]]) {
|
||||
chartData[this.positions[symbol][this.keys[0]]].value +=
|
||||
this.positions[symbol].value;
|
||||
|
||||
if (
|
||||
chartData[this.positions[symbol][this.keys[0]]].subCategory[
|
||||
this.positions[symbol][this.keys[1]]
|
||||
]
|
||||
) {
|
||||
chartData[this.positions[symbol][this.keys[0]]].subCategory[
|
||||
this.positions[symbol][this.keys[1]]
|
||||
].value += this.positions[symbol].value;
|
||||
} else {
|
||||
chartData[this.positions[symbol][this.keys[0]]].subCategory[
|
||||
this.positions[symbol][this.keys[1]] ?? UNKNOWN_KEY
|
||||
] = { value: this.positions[symbol].value };
|
||||
}
|
||||
} else {
|
||||
chartData[this.positions[symbol][this.key]] = {
|
||||
chartData[this.positions[symbol][this.keys[0]]] = {
|
||||
subCategory: {},
|
||||
value: this.positions[symbol].value
|
||||
};
|
||||
|
||||
if (this.positions[symbol][this.keys[1]]) {
|
||||
chartData[this.positions[symbol][this.keys[0]]].subCategory = {
|
||||
[this.positions[symbol][this.keys[1]]]: {
|
||||
value: this.positions[symbol].value
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (chartData[UNKNOWN_KEY]) {
|
||||
chartData[UNKNOWN_KEY].value += this.positions[symbol].value;
|
||||
} else {
|
||||
chartData[UNKNOWN_KEY] = {
|
||||
subCategory: this.keys[1]
|
||||
? { [this.keys[1]]: { value: 0 } }
|
||||
: undefined,
|
||||
value: this.positions[symbol].value
|
||||
};
|
||||
}
|
||||
@ -107,13 +138,17 @@ export class PortfolioProportionChartComponent
|
||||
});
|
||||
|
||||
if (!unknownItem) {
|
||||
const index = chartDataSorted.push([UNKNOWN_KEY, { value: 0 }]);
|
||||
const index = chartDataSorted.push([
|
||||
UNKNOWN_KEY,
|
||||
{ subCategory: {}, value: 0 }
|
||||
]);
|
||||
unknownItem = chartDataSorted[index];
|
||||
}
|
||||
|
||||
rest.forEach((restItem) => {
|
||||
if (unknownItem?.[1]) {
|
||||
unknownItem[1] = {
|
||||
subCategory: {},
|
||||
value: unknownItem[1].value + restItem[1].value
|
||||
};
|
||||
}
|
||||
@ -132,7 +167,8 @@ export class PortfolioProportionChartComponent
|
||||
// Reuse color
|
||||
item.color = this.colorMap[symbol];
|
||||
} else {
|
||||
const color = this.getColorPalette()[index];
|
||||
const color =
|
||||
this.getColorPalette()[index % this.getColorPalette().length];
|
||||
|
||||
// Store color for reuse
|
||||
this.colorMap[symbol] = color;
|
||||
@ -141,21 +177,53 @@ export class PortfolioProportionChartComponent
|
||||
}
|
||||
});
|
||||
|
||||
const backgroundColorSubCategory: string[] = [];
|
||||
const dataSubCategory: number[] = [];
|
||||
const labelSubCategory: string[] = [];
|
||||
|
||||
chartDataSorted.forEach(([, item]) => {
|
||||
let lightnessRatio = 0.2;
|
||||
|
||||
Object.keys(item.subCategory).forEach((subCategory) => {
|
||||
backgroundColorSubCategory.push(
|
||||
Color(item.color).lighten(lightnessRatio).hex()
|
||||
);
|
||||
dataSubCategory.push(item.subCategory[subCategory].value);
|
||||
labelSubCategory.push(subCategory);
|
||||
|
||||
lightnessRatio += 0.1;
|
||||
});
|
||||
});
|
||||
|
||||
const datasets = [
|
||||
{
|
||||
backgroundColor: chartDataSorted.map(([, item]) => {
|
||||
return item.color;
|
||||
}),
|
||||
borderWidth: 0,
|
||||
data: chartDataSorted.map(([, item]) => {
|
||||
return item.value;
|
||||
})
|
||||
}
|
||||
];
|
||||
|
||||
let labels = chartDataSorted.map(([label]) => {
|
||||
return label;
|
||||
});
|
||||
|
||||
if (this.keys[1]) {
|
||||
datasets.unshift({
|
||||
backgroundColor: backgroundColorSubCategory,
|
||||
borderWidth: 0,
|
||||
data: dataSubCategory
|
||||
});
|
||||
|
||||
labels = labelSubCategory.concat(labels);
|
||||
}
|
||||
|
||||
const data = {
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: chartDataSorted.map(([, item]) => {
|
||||
return item.color;
|
||||
}),
|
||||
borderWidth: 0,
|
||||
data: chartDataSorted.map(([, item]) => {
|
||||
return item.value;
|
||||
})
|
||||
}
|
||||
],
|
||||
labels: chartDataSorted.map(([label]) => {
|
||||
return label;
|
||||
})
|
||||
datasets,
|
||||
labels
|
||||
};
|
||||
|
||||
if (this.chartCanvas) {
|
||||
@ -166,13 +234,16 @@ export class PortfolioProportionChartComponent
|
||||
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
||||
data,
|
||||
options: {
|
||||
cutout: '70%',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const label =
|
||||
context.label === UNKNOWN_KEY ? 'Other' : context.label;
|
||||
const labelIndex =
|
||||
(data.datasets[context.datasetIndex - 1]?.data?.length ??
|
||||
0) + context.dataIndex;
|
||||
const label = context.chart.data.labels[labelIndex];
|
||||
|
||||
if (this.isInPercent) {
|
||||
const value = 100 * <number>context.raw;
|
||||
|
@ -102,9 +102,10 @@
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
label="Transactions"
|
||||
size="medium"
|
||||
[isCurrency]="true"
|
||||
[isCurrency]="false"
|
||||
[isInteger]="true"
|
||||
[label]="transactionCount === 1 ? 'Transaction' : 'Transactions'"
|
||||
[locale]="data.locale"
|
||||
[value]="transactionCount"
|
||||
></gf-value>
|
||||
|
@ -1,4 +1,8 @@
|
||||
<mat-radio-group [formControl]="option" (change)="onValueChange()">
|
||||
<mat-radio-group
|
||||
class="text-nowrap"
|
||||
[formControl]="option"
|
||||
(change)="onValueChange()"
|
||||
>
|
||||
<mat-radio-button
|
||||
*ngFor="let option of options"
|
||||
[disabled]="isLoading"
|
||||
|
@ -19,6 +19,7 @@ export class ValueComponent implements OnChanges, OnInit {
|
||||
@Input() colorizeSign: boolean;
|
||||
@Input() currency: string;
|
||||
@Input() isCurrency: boolean;
|
||||
@Input() isInteger: boolean;
|
||||
@Input() isPercent: boolean;
|
||||
@Input() label: string;
|
||||
@Input() locale: string;
|
||||
@ -84,6 +85,13 @@ export class ValueComponent implements OnChanges, OnInit {
|
||||
minimumFractionDigits: 2
|
||||
});
|
||||
} catch {}
|
||||
} else if (this.isInteger) {
|
||||
try {
|
||||
this.formattedValue = this.value?.toLocaleString(this.locale, {
|
||||
maximumFractionDigits: 0,
|
||||
minimumFractionDigits: 0
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
|
@ -35,12 +35,7 @@ export class AboutPageComponent implements OnDestroy, OnInit {
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Initializes the controller
|
||||
*/
|
||||
public ngOnInit() {
|
||||
) {
|
||||
const { globalPermissions, statistics } = this.dataService.fetchInfo();
|
||||
|
||||
this.hasPermissionForBlog = hasPermission(
|
||||
@ -59,7 +54,12 @@ export class AboutPageComponent implements OnDestroy, OnInit {
|
||||
);
|
||||
|
||||
this.statistics = statistics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the controller
|
||||
*/
|
||||
public ngOnInit() {
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
|
@ -10,8 +10,10 @@
|
||||
stocks, ETFs or cryptocurrencies and make solid, data-driven
|
||||
investment decisions. The source code is fully available as open
|
||||
source software (OSS). The project has been initiated by
|
||||
<a href="https://dotsilver.ch">Thomas Kaul</a> and is driven by the
|
||||
efforts of its
|
||||
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
|
||||
>Thomas Kaul</a
|
||||
>
|
||||
and is driven by the efforts of its
|
||||
<a
|
||||
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
|
||||
title="Contributors to Ghostfolio"
|
||||
@ -19,16 +21,32 @@
|
||||
>.
|
||||
<ng-container *ngIf="lastPublish">
|
||||
This instance is running Ghostfolio {{ version }} and has been
|
||||
last published on {{ lastPublish }}.</ng-container
|
||||
last published on {{ lastPublish }}.
|
||||
</ng-container>
|
||||
<ng-container *ngIf="hasPermissionForSubscription" i18n
|
||||
>Check the system status at
|
||||
<a href="https://status.ghostfol.io" title="Ghostfolio status"
|
||||
>status.ghostfol.io</a
|
||||
>.</ng-container
|
||||
>
|
||||
</p>
|
||||
<p>
|
||||
If you encounter a bug or would like to suggest an improvement or a
|
||||
new feature, please tweet to
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>, send an
|
||||
e-mail to <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or open
|
||||
an issue at
|
||||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
title="Tweet to Ghostfolio on Twitter"
|
||||
>@ghostfolio_</a
|
||||
>, send an e-mail to
|
||||
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
|
||||
>hi@ghostfol.io</a
|
||||
>
|
||||
or open an issue at
|
||||
<a
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
title="Find Ghostfolio on GitHub"
|
||||
>GitHub</a
|
||||
>.
|
||||
</p>
|
||||
<p class="text-center">
|
||||
<a
|
||||
|
@ -9,12 +9,12 @@
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-content>
|
||||
<div *ngIf="user.alias" class="d-flex py-1">
|
||||
<div class="w-50" i18n>Alias</div>
|
||||
<div class="w-50">{{ user.alias }}</div>
|
||||
<div class="pr-1 w-50" i18n>Alias</div>
|
||||
<div class="pl-1 w-50">{{ user.alias }}</div>
|
||||
</div>
|
||||
<div *ngIf="user?.subscription" class="d-flex py-1">
|
||||
<div class="w-50" i18n>Membership</div>
|
||||
<div class="w-50">
|
||||
<div class="pr-1 w-50" i18n>Membership</div>
|
||||
<div class="pl-1 w-50">
|
||||
<div class="align-items-center d-flex mb-1">
|
||||
{{ user?.subscription?.type }}
|
||||
<ion-icon
|
||||
@ -51,13 +51,14 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
<div class="w-50">
|
||||
<div i18n>Restricted View</div>
|
||||
<div class="pr-1 w-50">
|
||||
<div i18n>Presenter View</div>
|
||||
<div class="hint-text text-muted" i18n>
|
||||
Hides absolute values like performances and quantities.
|
||||
Hides sensitive values such as absolute performances and
|
||||
quantities.
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-50">
|
||||
<div class="pl-1 w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
[checked]="user.settings.isRestrictedView"
|
||||
@ -69,10 +70,10 @@
|
||||
<div class="d-flex mt-4 py-1">
|
||||
<form #changeUserSettingsForm="ngForm" class="w-100">
|
||||
<div class="d-flex mb-2">
|
||||
<div class="align-items-center d-flex pt-1 w-50" i18n>
|
||||
<div class="align-items-center d-flex pt-1 pt-1 w-50" i18n>
|
||||
Base Currency
|
||||
</div>
|
||||
<div class="w-50">
|
||||
<div class="pl-1 w-50">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-select
|
||||
name="baseCurrency"
|
||||
@ -90,7 +91,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="align-items-center d-flex pt-1 w-50" i18n>
|
||||
<div class="align-items-center d-flex pr-1 pt-1 w-50" i18n>
|
||||
View Mode
|
||||
<ion-icon
|
||||
*ngIf="!hasPermissionToUpdateViewMode"
|
||||
@ -98,7 +99,7 @@
|
||||
name="diamond-outline"
|
||||
></ion-icon>
|
||||
</div>
|
||||
<div class="w-50">
|
||||
<div class="pl-1 w-50">
|
||||
<div class="align-items-center d-flex overflow-hidden">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-select
|
||||
@ -117,8 +118,8 @@
|
||||
</form>
|
||||
</div>
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
<div class="w-50" i18n>Sign in with fingerprint</div>
|
||||
<div class="w-50">
|
||||
<div class="pr-1 w-50" i18n>Sign in with fingerprint</div>
|
||||
<div class="pl-1 w-50">
|
||||
<mat-slide-toggle
|
||||
#toggleSignInWithFingerprintEnabledElement
|
||||
color="primary"
|
||||
|
@ -116,7 +116,20 @@
|
||||
<tr *ngFor="let userItem of users; let i = index" class="mat-row">
|
||||
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
{{ userItem.alias || userItem.id }}
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="d-none d-sm-inline-block"
|
||||
>{{ userItem.alias || userItem.id }}</span
|
||||
>
|
||||
<span class="d-inline-block d-sm-none"
|
||||
>{{ userItem.alias || (userItem.id | slice:0:5) +
|
||||
'...' }}</span
|
||||
>
|
||||
<ion-icon
|
||||
*ngIf="userItem?.subscription?.type === 'Premium'"
|
||||
class="ml-1 text-muted"
|
||||
name="diamond-outline"
|
||||
></ion-icon>
|
||||
</div>
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2 text-right">
|
||||
{{ formatDistanceToNow(userItem.createdAt) }}
|
||||
|
@ -58,6 +58,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
|
||||
public fearAndGreedIndex: number;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public hasPositions: boolean;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public isLoadingPerformance = true;
|
||||
@ -119,6 +120,11 @@ export class HomePageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
@ -135,6 +141,8 @@ export class HomePageComponent implements OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((aId) => {
|
||||
this.hasImpersonationId = !!aId;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.dateRange =
|
||||
|
@ -91,18 +91,28 @@
|
||||
(change)="onChangeDateRange($event.value)"
|
||||
></gf-toggle>
|
||||
</div>
|
||||
|
||||
<mat-card *ngIf="hasPositions === true" class="p-0">
|
||||
<mat-card-content>
|
||||
<gf-positions
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
[range]="dateRange"
|
||||
></gf-positions>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<ng-container *ngIf="hasPositions === true">
|
||||
<mat-card class="p-0">
|
||||
<mat-card-content>
|
||||
<gf-positions
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
[range]="dateRange"
|
||||
></gf-positions>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
|
||||
<a
|
||||
class="mt-3"
|
||||
i18n
|
||||
mat-button
|
||||
[routerLink]="['/portfolio', 'transactions']"
|
||||
>Manage Transactions...</a
|
||||
>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div
|
||||
*ngIf="hasPositions === false"
|
||||
class="d-flex justify-content-center"
|
||||
|
@ -4,7 +4,12 @@ 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 { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPosition,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { AssetClass } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -31,12 +36,16 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
{ label: 'Initial', value: 'original' },
|
||||
{ label: 'Current', value: 'current' }
|
||||
];
|
||||
public portfolioPositions: { [symbol: string]: PortfolioPosition };
|
||||
public portfolioDetails: PortfolioDetails;
|
||||
public positions: { [symbol: string]: any };
|
||||
public positionsArray: PortfolioPosition[];
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
public symbols: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -66,11 +75,12 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioPositions({})
|
||||
.fetchPortfolioDetails({})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((response = {}) => {
|
||||
this.portfolioPositions = response;
|
||||
this.initializeAnalysisData(this.portfolioPositions, this.period);
|
||||
.subscribe((portfolioDetails) => {
|
||||
this.portfolioDetails = portfolioDetails;
|
||||
|
||||
this.initializeAnalysisData(this.period);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
@ -86,12 +96,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public initializeAnalysisData(
|
||||
aPortfolioPositions: {
|
||||
[symbol: string]: PortfolioPosition;
|
||||
},
|
||||
aPeriod: string
|
||||
) {
|
||||
public initializeAnalysisData(aPeriod: string) {
|
||||
this.accounts = {};
|
||||
this.continents = {
|
||||
[UNKNOWN_KEY]: {
|
||||
@ -113,10 +118,28 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
value: 0
|
||||
}
|
||||
};
|
||||
this.symbols = {
|
||||
[UNKNOWN_KEY]: {
|
||||
name: UNKNOWN_KEY,
|
||||
value: 0
|
||||
}
|
||||
};
|
||||
|
||||
for (const [symbol, position] of Object.entries(aPortfolioPositions)) {
|
||||
for (const [name, { current, original }] of Object.entries(
|
||||
this.portfolioDetails.accounts
|
||||
)) {
|
||||
this.accounts[name] = {
|
||||
name,
|
||||
value: aPeriod === 'original' ? original : current
|
||||
};
|
||||
}
|
||||
|
||||
for (const [symbol, position] of Object.entries(
|
||||
this.portfolioDetails.holdings
|
||||
)) {
|
||||
this.positions[symbol] = {
|
||||
assetClass: position.assetClass,
|
||||
assetSubClass: position.assetSubClass,
|
||||
currency: position.currency,
|
||||
exchange: position.exchange,
|
||||
value:
|
||||
@ -126,84 +149,81 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
};
|
||||
this.positionsArray.push(position);
|
||||
|
||||
for (const [account, { current, original }] of Object.entries(
|
||||
position.accounts
|
||||
)) {
|
||||
if (this.accounts[account]?.value) {
|
||||
this.accounts[account].value +=
|
||||
aPeriod === 'original' ? original : current;
|
||||
if (position.assetClass !== AssetClass.CASH) {
|
||||
// Prepare analysis data by continents, countries and sectors except for cash
|
||||
|
||||
if (position.countries.length > 0) {
|
||||
for (const country of position.countries) {
|
||||
const { code, continent, name, weight } = country;
|
||||
|
||||
if (this.continents[continent]?.value) {
|
||||
this.continents[continent].value += weight * position.value;
|
||||
} else {
|
||||
this.continents[continent] = {
|
||||
name: continent,
|
||||
value:
|
||||
weight *
|
||||
(aPeriod === 'original'
|
||||
? this.portfolioDetails.holdings[symbol].investment
|
||||
: this.portfolioDetails.holdings[symbol].value)
|
||||
};
|
||||
}
|
||||
|
||||
if (this.countries[code]?.value) {
|
||||
this.countries[code].value += weight * position.value;
|
||||
} else {
|
||||
this.countries[code] = {
|
||||
name,
|
||||
value:
|
||||
weight *
|
||||
(aPeriod === 'original'
|
||||
? this.portfolioDetails.holdings[symbol].investment
|
||||
: this.portfolioDetails.holdings[symbol].value)
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.accounts[account] = {
|
||||
name: account,
|
||||
value: aPeriod === 'original' ? original : current
|
||||
};
|
||||
this.continents[UNKNOWN_KEY].value +=
|
||||
aPeriod === 'original'
|
||||
? this.portfolioDetails.holdings[symbol].investment
|
||||
: this.portfolioDetails.holdings[symbol].value;
|
||||
|
||||
this.countries[UNKNOWN_KEY].value +=
|
||||
aPeriod === 'original'
|
||||
? this.portfolioDetails.holdings[symbol].investment
|
||||
: this.portfolioDetails.holdings[symbol].value;
|
||||
}
|
||||
|
||||
if (position.sectors.length > 0) {
|
||||
for (const sector of position.sectors) {
|
||||
const { name, weight } = sector;
|
||||
|
||||
if (this.sectors[name]?.value) {
|
||||
this.sectors[name].value += weight * position.value;
|
||||
} else {
|
||||
this.sectors[name] = {
|
||||
name,
|
||||
value:
|
||||
weight *
|
||||
(aPeriod === 'original'
|
||||
? this.portfolioDetails.holdings[symbol].investment
|
||||
: this.portfolioDetails.holdings[symbol].value)
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.sectors[UNKNOWN_KEY].value +=
|
||||
aPeriod === 'original'
|
||||
? this.portfolioDetails.holdings[symbol].investment
|
||||
: this.portfolioDetails.holdings[symbol].value;
|
||||
}
|
||||
}
|
||||
|
||||
if (position.countries.length > 0) {
|
||||
for (const country of position.countries) {
|
||||
const { code, continent, name, weight } = country;
|
||||
|
||||
if (this.continents[continent]?.value) {
|
||||
this.continents[continent].value += weight * position.value;
|
||||
} else {
|
||||
this.continents[continent] = {
|
||||
name: continent,
|
||||
value:
|
||||
weight *
|
||||
(aPeriod === 'original'
|
||||
? this.portfolioPositions[symbol].investment
|
||||
: this.portfolioPositions[symbol].value)
|
||||
};
|
||||
}
|
||||
|
||||
if (this.countries[code]?.value) {
|
||||
this.countries[code].value += weight * position.value;
|
||||
} else {
|
||||
this.countries[code] = {
|
||||
name,
|
||||
value:
|
||||
weight *
|
||||
(aPeriod === 'original'
|
||||
? this.portfolioPositions[symbol].investment
|
||||
: this.portfolioPositions[symbol].value)
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.continents[UNKNOWN_KEY].value +=
|
||||
aPeriod === 'original'
|
||||
? this.portfolioPositions[symbol].investment
|
||||
: this.portfolioPositions[symbol].value;
|
||||
|
||||
this.countries[UNKNOWN_KEY].value +=
|
||||
aPeriod === 'original'
|
||||
? this.portfolioPositions[symbol].investment
|
||||
: this.portfolioPositions[symbol].value;
|
||||
}
|
||||
|
||||
if (position.sectors.length > 0) {
|
||||
for (const sector of position.sectors) {
|
||||
const { name, weight } = sector;
|
||||
|
||||
if (this.sectors[name]?.value) {
|
||||
this.sectors[name].value += weight * position.value;
|
||||
} else {
|
||||
this.sectors[name] = {
|
||||
name,
|
||||
value:
|
||||
weight *
|
||||
(aPeriod === 'original'
|
||||
? this.portfolioPositions[symbol].investment
|
||||
: this.portfolioPositions[symbol].value)
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.sectors[UNKNOWN_KEY].value +=
|
||||
aPeriod === 'original'
|
||||
? this.portfolioPositions[symbol].investment
|
||||
: this.portfolioPositions[symbol].value;
|
||||
if (position.assetClass === AssetClass.EQUITY) {
|
||||
this.symbols[symbol] = {
|
||||
name: symbol,
|
||||
value: aPeriod === 'original' ? position.investment : position.value
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -211,7 +231,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
public onChangePeriod(aValue: string) {
|
||||
this.period = aValue;
|
||||
|
||||
this.initializeAnalysisData(this.portfolioPositions, this.period);
|
||||
this.initializeAnalysisData(this.period);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
|
@ -5,10 +5,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="proportion-charts row">
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
<mat-card-title i18n>By Account</mat-card-title>
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n>By Account</mat-card-title>
|
||||
<gf-toggle
|
||||
[defaultValue]="period"
|
||||
[isLoading]="false"
|
||||
@ -18,19 +18,21 @@
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
key="name"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="hasImpersonationId"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="accounts"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
<mat-card-title i18n>By Asset Class</mat-card-title>
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n
|
||||
>By Asset Class</mat-card-title
|
||||
>
|
||||
<gf-toggle
|
||||
[defaultValue]="period"
|
||||
[isLoading]="false"
|
||||
@ -40,19 +42,21 @@
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
key="assetClass"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="true"
|
||||
[keys]="['assetClass', 'assetSubClass']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
<mat-card-title i18n>By Currency</mat-card-title>
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n
|
||||
>By Currency</mat-card-title
|
||||
>
|
||||
<gf-toggle
|
||||
[defaultValue]="period"
|
||||
[isLoading]="false"
|
||||
@ -62,19 +66,19 @@
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
key="currency"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="true"
|
||||
[keys]="['currency']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-12 allocations-by-symbol">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
<mat-card-title i18n>By Sector</mat-card-title>
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n>By Symbol</mat-card-title>
|
||||
<gf-toggle
|
||||
[defaultValue]="period"
|
||||
[isLoading]="false"
|
||||
@ -84,9 +88,32 @@
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
key="name"
|
||||
class="mx-auto"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="false"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="symbols"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n>By Sector</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
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="false"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[maxItems]="10"
|
||||
[positions]="sectors"
|
||||
@ -94,10 +121,12 @@
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
<mat-card-title i18n>By Continent</mat-card-title>
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n
|
||||
>By Continent</mat-card-title
|
||||
>
|
||||
<gf-toggle
|
||||
[defaultValue]="period"
|
||||
[isLoading]="false"
|
||||
@ -107,19 +136,19 @@
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
key="name"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="false"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="continents"
|
||||
></gf-portfolio-proportion-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="col-md-4">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
<mat-card-title i18n>By Country</mat-card-title>
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n>By Country</mat-card-title>
|
||||
<gf-toggle
|
||||
[defaultValue]="period"
|
||||
[isLoading]="false"
|
||||
@ -129,7 +158,7 @@
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
key="name"
|
||||
[keys]="['name']"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="false"
|
||||
[locale]="user?.settings?.locale"
|
||||
@ -143,8 +172,8 @@
|
||||
<div class="row world-map-chart">
|
||||
<div class="col-lg">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="w-100">
|
||||
<mat-card-title i18n>Regions</mat-card-title>
|
||||
<mat-card-header class="overflow-hidden w-100">
|
||||
<mat-card-title class="text-truncate" i18n>Regions</mat-card-title>
|
||||
<gf-toggle
|
||||
[defaultValue]="period"
|
||||
[isLoading]="false"
|
||||
|
@ -1,9 +1,7 @@
|
||||
:host {
|
||||
.proportion-charts {
|
||||
.mat-card {
|
||||
.mat-card-content {
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
.allocations-by-symbol {
|
||||
gf-portfolio-proportion-chart {
|
||||
max-width: 67vh;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -11,8 +11,9 @@ 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 { EMPTY, Observable, Subject } from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
startWith,
|
||||
@ -49,7 +50,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
public ngOnInit() {
|
||||
const { currencies, platforms } = this.dataService.fetchInfo();
|
||||
|
||||
this.currencies = currencies;
|
||||
@ -84,17 +85,45 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
this.data.transaction.unitPrice = this.currentMarketPrice;
|
||||
}
|
||||
|
||||
public onBlurSymbol() {
|
||||
const symbol = this.searchSymbolCtrl.value;
|
||||
this.updateSymbol(symbol);
|
||||
}
|
||||
|
||||
public onCancel(): void {
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
||||
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
|
||||
this.updateSymbol(event.option.value);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private updateSymbol(symbol: string) {
|
||||
this.isLoading = true;
|
||||
this.data.transaction.symbol = event.option.value;
|
||||
|
||||
this.data.transaction.symbol = symbol;
|
||||
|
||||
this.dataService
|
||||
.fetchSymbolItem(this.data.transaction.symbol)
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.data.transaction.currency = null;
|
||||
this.data.transaction.dataSource = null;
|
||||
this.data.transaction.unitPrice = null;
|
||||
|
||||
this.isLoading = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
)
|
||||
.subscribe(({ currency, dataSource, marketPrice }) => {
|
||||
this.data.transaction.currency = currency;
|
||||
this.data.transaction.dataSource = dataSource;
|
||||
@ -105,17 +134,4 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
|
||||
public onUpdateSymbolByTyping(value: string) {
|
||||
this.data.transaction.currency = null;
|
||||
this.data.transaction.dataSource = null;
|
||||
this.data.transaction.unitPrice = null;
|
||||
|
||||
this.data.transaction.symbol = value;
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
}
|
||||
|
@ -29,7 +29,7 @@
|
||||
required
|
||||
[formControl]="searchSymbolCtrl"
|
||||
[matAutocomplete]="auto"
|
||||
(change)="onUpdateSymbolByTyping($event.target.value)"
|
||||
(blur)="onBlurSymbol()"
|
||||
/>
|
||||
<mat-autocomplete
|
||||
#auto="matAutocomplete"
|
||||
|
@ -19,6 +19,7 @@ import {
|
||||
Position,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
@ -36,6 +37,7 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
|
||||
public dateRange: DateRange = 'max';
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public hasPositions: boolean;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public isLoadingPerformance = true;
|
||||
@ -63,6 +65,11 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
@ -76,6 +83,8 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((aId) => {
|
||||
this.hasImpersonationId = !!aId;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.update();
|
||||
|
@ -64,17 +64,28 @@
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Holdings</h3>
|
||||
<div class="row">
|
||||
<div class="align-items-center col">
|
||||
<mat-card *ngIf="hasPositions === true" class="p-0">
|
||||
<mat-card-content>
|
||||
<gf-positions
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
[range]="dateRange"
|
||||
></gf-positions>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<ng-container *ngIf="hasPositions === true">
|
||||
<mat-card class="p-0">
|
||||
<mat-card-content>
|
||||
<gf-positions
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
[range]="dateRange"
|
||||
></gf-positions>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
|
||||
<a
|
||||
class="mt-3"
|
||||
i18n
|
||||
mat-button
|
||||
[routerLink]="['/portfolio', 'transactions']"
|
||||
>Manage Transactions...</a
|
||||
>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div
|
||||
*ngIf="hasPositions === false"
|
||||
class="d-flex justify-content-center"
|
||||
|
@ -20,8 +20,8 @@ import {
|
||||
AdminData,
|
||||
Export,
|
||||
InfoItem,
|
||||
PortfolioDetails,
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
PortfolioSummary,
|
||||
User
|
||||
@ -148,17 +148,16 @@ export class DataService {
|
||||
return this.http.get<InvestmentItem[]>('/api/portfolio/investments');
|
||||
}
|
||||
|
||||
public fetchPortfolioPerformance(aParams: { [param: string]: any }) {
|
||||
return this.http.get<PortfolioPerformance>('/api/portfolio/performance', {
|
||||
public fetchPortfolioDetails(aParams: { [param: string]: any }) {
|
||||
return this.http.get<PortfolioDetails>('/api/portfolio/details', {
|
||||
params: aParams
|
||||
});
|
||||
}
|
||||
|
||||
public fetchPortfolioPositions(aParams: { [param: string]: any }) {
|
||||
return this.http.get<{ [symbol: string]: PortfolioPosition }>(
|
||||
'/api/portfolio/details',
|
||||
{ params: aParams }
|
||||
);
|
||||
public fetchPortfolioPerformance(aParams: { [param: string]: any }) {
|
||||
return this.http.get<PortfolioPerformance>('/api/portfolio/performance', {
|
||||
params: aParams
|
||||
});
|
||||
}
|
||||
|
||||
public fetchPortfolioReport() {
|
||||
|
@ -1,6 +1,7 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
|
||||
@import './styles/bootstrap';
|
||||
@import './styles/table';
|
||||
|
||||
@import '~angular-material-css-vars/main';
|
||||
|
||||
@ -68,6 +69,10 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.gf-table {
|
||||
@include gf-table(true);
|
||||
}
|
||||
|
||||
.mat-card {
|
||||
background: var(--dark-background);
|
||||
|
||||
@ -129,6 +134,10 @@ ngx-skeleton-loader {
|
||||
}
|
||||
}
|
||||
|
||||
.gf-table {
|
||||
@include gf-table;
|
||||
}
|
||||
|
||||
.mat-fab,
|
||||
.mat-flat-button {
|
||||
&.mat-primary {
|
||||
@ -147,18 +156,6 @@ ngx-skeleton-loader {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.mat-row {
|
||||
&:last-child {
|
||||
td.mat-cell {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-table {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.no-min-width {
|
||||
min-width: unset !important;
|
||||
}
|
||||
|
@ -4,31 +4,3 @@ $mat-css-dark-theme-selector: '.is-dark-theme';
|
||||
|
||||
$alpha-disabled-text: 0.38;
|
||||
$alpha-hover: 0.04;
|
||||
|
||||
.gf-table {
|
||||
td {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.mat-row {
|
||||
&:nth-child(even) {
|
||||
background-color: rgba(
|
||||
var(--dark-primary-text),
|
||||
var(--palette-background-hover-alpha)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.is-dark-theme {
|
||||
.gf-table {
|
||||
.mat-row {
|
||||
&:nth-child(even) {
|
||||
background-color: rgba(
|
||||
var(--light-primary-text),
|
||||
var(--palette-background-hover-alpha)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
27
apps/client/src/styles/table.scss
Normal file
27
apps/client/src/styles/table.scss
Normal file
@ -0,0 +1,27 @@
|
||||
@mixin gf-table($darkTheme: false) {
|
||||
background: transparent !important;
|
||||
|
||||
td {
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.mat-row {
|
||||
&:nth-child(even) {
|
||||
background-color: rgba(
|
||||
var(--palette-foreground-base),
|
||||
var(--palette-background-hover-alpha)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@if $darkTheme {
|
||||
.mat-row {
|
||||
&:nth-child(even) {
|
||||
background-color: rgba(
|
||||
var(--palette-foreground-base-dark),
|
||||
var(--palette-background-hover-alpha)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,7 +1,3 @@
|
||||
module.exports = {
|
||||
projects: [
|
||||
'<rootDir>/apps/api',
|
||||
'<rootDir>/apps/client',
|
||||
'<rootDir>/libs/common'
|
||||
]
|
||||
};
|
||||
const { getJestProjects } = require('@nrwl/jest');
|
||||
|
||||
module.exports = { projects: getJestProjects() };
|
||||
|
@ -13,7 +13,7 @@ export const currencyPairs: Partial<
|
||||
currency1: Currency;
|
||||
currency2: Currency;
|
||||
}
|
||||
>[] = Object.keys(Currency)
|
||||
>[] = (Object.keys(Currency) as Array<keyof typeof Currency>)
|
||||
.filter((currency) => {
|
||||
return currency !== Currency.USD;
|
||||
})
|
||||
|
@ -2,6 +2,7 @@ import { Access } from './access.interface';
|
||||
import { AdminData } from './admin-data.interface';
|
||||
import { Export } from './export.interface';
|
||||
import { InfoItem } from './info-item.interface';
|
||||
import { PortfolioDetails } from './portfolio-details.interface';
|
||||
import { PortfolioItem } from './portfolio-item.interface';
|
||||
import { PortfolioOverview } from './portfolio-overview.interface';
|
||||
import { PortfolioPerformance } from './portfolio-performance.interface';
|
||||
@ -20,6 +21,7 @@ export {
|
||||
AdminData,
|
||||
Export,
|
||||
InfoItem,
|
||||
PortfolioDetails,
|
||||
PortfolioItem,
|
||||
PortfolioOverview,
|
||||
PortfolioPerformance,
|
||||
|
@ -0,0 +1,8 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface PortfolioDetails {
|
||||
accounts: {
|
||||
[name: string]: { current: number; original: number };
|
||||
};
|
||||
holdings: { [symbol: string]: PortfolioPosition };
|
||||
}
|
@ -1,16 +1,14 @@
|
||||
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { AssetClass, Currency } from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, Currency } from '@prisma/client';
|
||||
|
||||
import { Country } from './country.interface';
|
||||
import { Sector } from './sector.interface';
|
||||
|
||||
export interface PortfolioPosition {
|
||||
accounts: {
|
||||
[name: string]: { current: number; original: number };
|
||||
};
|
||||
allocationCurrent: number;
|
||||
allocationInvestment: number;
|
||||
assetClass?: AssetClass;
|
||||
assetSubClass?: AssetSubClass | 'CASH';
|
||||
countries: Country[];
|
||||
currency: Currency;
|
||||
exchange?: string;
|
||||
|
66
package.json
66
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.40.0",
|
||||
"version": "1.45.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -36,7 +36,7 @@
|
||||
"start": "node dist/apps/api/main",
|
||||
"start:client": "ng serve client --hmr -o",
|
||||
"start:prod": "node apps/api/main",
|
||||
"start:server": "nx serve api",
|
||||
"start:server": "nx serve api --watch",
|
||||
"test": "nx test",
|
||||
"ts-node": "ts-node --compiler-options '{\"module\":\"CommonJS\"}'",
|
||||
"update": "nx migrate latest",
|
||||
@ -45,16 +45,16 @@
|
||||
"workspace-generator": "nx workspace-generator"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "12.0.4",
|
||||
"@angular/cdk": "12.0.6",
|
||||
"@angular/common": "12.0.4",
|
||||
"@angular/compiler": "12.0.4",
|
||||
"@angular/core": "12.0.4",
|
||||
"@angular/forms": "12.0.4",
|
||||
"@angular/material": "12.0.6",
|
||||
"@angular/platform-browser": "12.0.4",
|
||||
"@angular/platform-browser-dynamic": "12.0.4",
|
||||
"@angular/router": "12.0.4",
|
||||
"@angular/animations": "12.2.4",
|
||||
"@angular/cdk": "12.2.4",
|
||||
"@angular/common": "12.2.4",
|
||||
"@angular/compiler": "12.2.4",
|
||||
"@angular/core": "12.2.4",
|
||||
"@angular/forms": "12.2.4",
|
||||
"@angular/material": "12.2.4",
|
||||
"@angular/platform-browser": "12.2.4",
|
||||
"@angular/platform-browser-dynamic": "12.2.4",
|
||||
"@angular/router": "12.2.4",
|
||||
"@codewithdan/observable-store": "2.2.11",
|
||||
"@nestjs/common": "7.6.18",
|
||||
"@nestjs/config": "0.6.3",
|
||||
@ -64,8 +64,8 @@
|
||||
"@nestjs/platform-express": "7.6.18",
|
||||
"@nestjs/schedule": "0.4.3",
|
||||
"@nestjs/serve-static": "2.1.4",
|
||||
"@nrwl/angular": "12.5.4",
|
||||
"@prisma/client": "2.24.1",
|
||||
"@nrwl/angular": "12.8.0",
|
||||
"@prisma/client": "2.30.2",
|
||||
"@simplewebauthn/browser": "3.0.0",
|
||||
"@simplewebauthn/server": "3.0.0",
|
||||
"@simplewebauthn/typescript-types": "3.0.0",
|
||||
@ -82,6 +82,7 @@
|
||||
"cheerio": "1.0.0-rc.6",
|
||||
"class-transformer": "0.3.2",
|
||||
"class-validator": "0.13.1",
|
||||
"color": "4.0.1",
|
||||
"countries-list": "2.6.1",
|
||||
"countup.js": "2.0.7",
|
||||
"cryptocurrencies": "7.0.0",
|
||||
@ -97,35 +98,36 @@
|
||||
"passport": "0.4.1",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.0",
|
||||
"prisma": "2.24.1",
|
||||
"prisma": "2.30.2",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"round-to": "5.0.0",
|
||||
"rxjs": "6.6.7",
|
||||
"stripe": "8.156.0",
|
||||
"svgmap": "2.1.1",
|
||||
"svgmap": "2.6.0",
|
||||
"uuid": "8.3.2",
|
||||
"yahoo-finance": "0.3.6",
|
||||
"zone.js": "0.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "12.0.4",
|
||||
"@angular-eslint/eslint-plugin": "12.0.0",
|
||||
"@angular/cli": "12.0.4",
|
||||
"@angular/compiler-cli": "12.0.4",
|
||||
"@angular/language-service": "12.0.4",
|
||||
"@angular/localize": "12.0.5",
|
||||
"@angular-devkit/build-angular": "12.2.4",
|
||||
"@angular-eslint/eslint-plugin": "12.3.1",
|
||||
"@angular/cli": "12.2.4",
|
||||
"@angular/compiler-cli": "12.2.4",
|
||||
"@angular/language-service": "12.2.4",
|
||||
"@angular/localize": "12.2.4",
|
||||
"@nestjs/schematics": "7.3.1",
|
||||
"@nestjs/testing": "7.6.18",
|
||||
"@nrwl/cli": "12.5.4",
|
||||
"@nrwl/cypress": "12.5.4",
|
||||
"@nrwl/eslint-plugin-nx": "12.5.4",
|
||||
"@nrwl/jest": "12.5.4",
|
||||
"@nrwl/nest": "12.5.4",
|
||||
"@nrwl/node": "12.5.4",
|
||||
"@nrwl/tao": "12.5.4",
|
||||
"@nrwl/workspace": "12.5.4",
|
||||
"@nrwl/cli": "12.8.0",
|
||||
"@nrwl/cypress": "12.8.0",
|
||||
"@nrwl/eslint-plugin-nx": "12.8.0",
|
||||
"@nrwl/jest": "12.8.0",
|
||||
"@nrwl/nest": "12.8.0",
|
||||
"@nrwl/node": "12.8.0",
|
||||
"@nrwl/tao": "12.8.0",
|
||||
"@nrwl/workspace": "12.8.0",
|
||||
"@types/big.js": "6.1.1",
|
||||
"@types/cache-manager": "3.4.0",
|
||||
"@types/color": "3.0.2",
|
||||
"@types/jest": "26.0.20",
|
||||
"@types/lodash": "4.14.168",
|
||||
"@types/node": "14.14.33",
|
||||
@ -134,7 +136,7 @@
|
||||
"@typescript-eslint/parser": "4.27.0",
|
||||
"codelyzer": "6.0.1",
|
||||
"cypress": "6.2.1",
|
||||
"dotenv": "8.2.0",
|
||||
"dotenv": "10.0.0",
|
||||
"eslint": "7.28.0",
|
||||
"eslint-config-prettier": "8.3.0",
|
||||
"eslint-plugin-import": "2.23.4",
|
||||
@ -148,7 +150,7 @@
|
||||
"rimraf": "3.0.2",
|
||||
"ts-jest": "27.0.3",
|
||||
"ts-node": "9.1.1",
|
||||
"typescript": "4.2.4"
|
||||
"typescript": "4.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
|
@ -0,0 +1,5 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AssetSubClass" AS ENUM ('CRYPTOCURRENCY', 'ETF', 'STOCK');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "SymbolProfile" ADD COLUMN "assetSubClass" "AssetSubClass";
|
@ -117,17 +117,18 @@ model Settings {
|
||||
}
|
||||
|
||||
model SymbolProfile {
|
||||
assetClass AssetClass?
|
||||
countries Json?
|
||||
createdAt DateTime @default(now())
|
||||
currency Currency?
|
||||
dataSource DataSource
|
||||
id String @id @default(uuid())
|
||||
name String?
|
||||
Order Order[]
|
||||
updatedAt DateTime @updatedAt
|
||||
sectors Json?
|
||||
symbol String
|
||||
assetClass AssetClass?
|
||||
assetSubClass AssetSubClass?
|
||||
countries Json?
|
||||
createdAt DateTime @default(now())
|
||||
currency Currency?
|
||||
dataSource DataSource
|
||||
id String @id @default(uuid())
|
||||
name String?
|
||||
Order Order[]
|
||||
updatedAt DateTime @updatedAt
|
||||
sectors Json?
|
||||
symbol String
|
||||
|
||||
@@unique([dataSource, symbol])
|
||||
}
|
||||
@ -174,6 +175,12 @@ enum AssetClass {
|
||||
EQUITY
|
||||
}
|
||||
|
||||
enum AssetSubClass {
|
||||
CRYPTOCURRENCY
|
||||
ETF
|
||||
STOCK
|
||||
}
|
||||
|
||||
enum Currency {
|
||||
CHF
|
||||
EUR
|
||||
|
Reference in New Issue
Block a user