Compare commits

..

18 Commits

Author SHA1 Message Date
3b857aa8bb Release 1.80.0 (#487) 2021-11-23 20:28:50 +01:00
1c2ca5b96b Feature/accentuate all time high and low (#428)
* Accentuate all time high and all time low

* Update changelog

Co-authored-by: Valentin Zickner <ghostfolio@zickner.ch>
2021-11-22 21:28:32 +01:00
572bfc59b8 Add guards (#486) 2021-11-22 20:35:25 +01:00
147f0162b7 Release 1.79.0 (#485) 2021-11-21 18:04:28 +01:00
f6acf5207b Feature/add value to positions table (#484)
* Add value column

* Update changelog
2021-11-21 17:55:58 +01:00
80782f1098 add support for euro cryptocurrencies, ALGO and remove unknown crypto… (#480)
* add support for euro cryptocurrencies, ALGO and remove unknown cryptocurrencies from list

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-11-21 17:38:48 +01:00
bc58ee86ca Feature/usability improvements in the create or edit transaction dialog (#483)
* Usability improvements
  * Disable the symbol input in edit mode
  * Filter accounts by type (SECURITIES)

* Update changelog
2021-11-20 20:41:33 +01:00
0cb632b165 Improve wording (#482) 2021-11-20 10:38:30 +01:00
fca3a659d0 Release 1.78.0 (#481) 2021-11-20 10:31:45 +01:00
904dec040e Feature/add testimonial (#479)
* Add testimonials

* Update changelog
2021-11-20 10:28:05 +01:00
fc6c81fe02 Bugfix/fix footer row border in dark mode (#478)
* Fix border color in dark mode

* Update changelog
2021-11-17 23:23:32 +01:00
634171e4e3 Release 1.77.0 (#476) 2021-11-16 21:58:41 +01:00
f8f36e4f4e Bugfix/fix accounts table footer on mobile (#475)
* Fix footer on mobile

* Update changelog
2021-11-16 21:32:04 +01:00
5e7cf9d0b6 Feature/hide get started button on registration page (#474)
* Hide button

* Update changelog
2021-11-15 20:49:03 +01:00
e1932eb5a1 Bugfix/exclude drafts from transaction count (#473)
* Fix transactions count (exclude drafts)

* Improve wording (summary page)

* Update changelog
2021-11-14 19:06:54 +01:00
dba47d59e3 Release 1.76.0 (#472) 2021-11-14 17:16:06 +01:00
3032126508 Feature/add footer row to accounts table (#471)
* Add footer row to accounts table with total balance and value

* Update changelog
2021-11-14 17:04:52 +01:00
a50b55da75 Clean up (#470) 2021-11-14 16:57:46 +01:00
41 changed files with 512 additions and 120 deletions

View File

@ -5,6 +5,55 @@ 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.80.0 - 23.11.2021
### Added
- Accentuated the all time high and the all time low
## 1.79.0 - 21.11.2021
### Added
- Added the value column to the positions table
- Added support for cryptocurrency _Algorand_
### Changed
- Locked the symbol input in the edit transaction dialog
- Filtered the account selector by account type (`SECURITIES`) in the create or edit transaction dialog
### Fixed
- Fixed the search functionality for cryptocurrency symbols (do not show unsupported symbols)
## 1.78.0 - 20.11.2021
### Added
- Added a testimonial section to the landing page
### Fixed
- Fixed the footer row border of the accounts table in dark mode
## 1.77.0 - 16.11.2021
### Changed
- Hid the _Get Started_ button on the registration page
### Fixed
- Fixed the footer row of the accounts table on mobile
- Fixed the transactions count calculation in the accounts table (exclude drafts)
## 1.76.0 - 14.11.2021
### Added
- Added the footer row with buying power and net worth to the accounts table
## 1.75.0 - 13.11.2021
### Added

View File

@ -1,16 +1,17 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import {
nullifyValuesInObject,
nullifyValuesInObjects
} from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { Accounts } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type {
AccountWithValue,
RequestWithUser
} from '@ghostfolio/common/types';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -90,32 +91,39 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'))
public async getAllAccounts(
@Headers('impersonation-id') impersonationId
): Promise<AccountWithValue[]> {
): Promise<Accounts> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let accounts = await this.portfolioService.getAccounts(
impersonationUserId || this.request.user.id
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations(
impersonationUserId || this.request.user.id
);
if (
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
accounts = nullifyValuesInObjects(accounts, [
'balance',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value'
]);
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalance',
'totalValue'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value'
])
};
}
return accounts;
return accountsWithAggregations;
}
@Get(':id')

View File

@ -85,7 +85,15 @@ export class AccountService {
});
return accounts.map((account) => {
const result = { ...account, transactionCount: account.Order.length };
let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
transactionCount += 1;
}
}
const result = { ...account, transactionCount };
delete result.Order;

View File

@ -21,6 +21,12 @@ export interface PortfolioPositionDetail {
transactionCount: number;
}
export interface HistoricalDataContainer {
isAllTimeHigh: boolean;
isAllTimeLow: boolean;
items: HistoricalDataItem[];
}
export interface HistoricalDataItem {
averagePrice?: number;
date: string;

View File

@ -0,0 +1,8 @@
import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface';
import Big from 'big.js';
export interface TimelineInfoInterface {
maxNetPerformance: Big;
minNetPerformance: Big;
timelinePeriods: TimelinePeriod[];
}

View File

@ -1502,11 +1502,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'year'
}
];
const timeline: TimelinePeriod[] =
await portfolioCalculator.calculateTimeline(
timelineSpecification,
'2021-06-30'
);
const timelineInfo = await portfolioCalculator.calculateTimeline(
timelineSpecification,
'2021-06-30'
);
const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([
{
@ -1622,11 +1622,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'year'
}
];
const timeline: TimelinePeriod[] =
await portfolioCalculator.calculateTimeline(
timelineSpecification,
'2021-06-30'
);
const timelineInfo = await portfolioCalculator.calculateTimeline(
timelineSpecification,
'2021-06-30'
);
const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([
{
@ -1665,11 +1665,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'month'
}
];
const timeline: TimelinePeriod[] =
await portfolioCalculator.calculateTimeline(
timelineSpecification,
'2021-06-30'
);
const timelineInfo = await portfolioCalculator.calculateTimeline(
timelineSpecification,
'2021-06-30'
);
const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([
{
@ -1883,6 +1883,9 @@ describe('PortfolioCalculator', () => {
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
}
]);
expect(timelineInfo.maxNetPerformance).toEqual(new Big('547.9'));
expect(timelineInfo.minNetPerformance).toEqual(new Big('0'));
});
it('with yearly and monthly mixed', async () => {
@ -1901,11 +1904,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'month'
}
];
const timeline: TimelinePeriod[] =
await portfolioCalculator.calculateTimeline(
timelineSpecification,
'2021-06-30'
);
const timelineInfo = await portfolioCalculator.calculateTimeline(
timelineSpecification,
'2021-06-30'
);
const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([
{
@ -1987,11 +1990,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'day'
}
];
const timeline: TimelinePeriod[] =
await portfolioCalculator.calculateTimeline(
timelineSpecification,
'2021-06-30'
);
const timelineInfo = await portfolioCalculator.calculateTimeline(
timelineSpecification,
'2021-06-30'
);
const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual(
expect.objectContaining([
@ -2296,11 +2299,11 @@ describe('PortfolioCalculator', () => {
accuracy: 'year'
}
];
const timeline: TimelinePeriod[] =
await portfolioCalculator.calculateTimeline(
timelineSpecification,
'2020-01-01'
);
const timelineInfo = await portfolioCalculator.calculateTimeline(
timelineSpecification,
'2020-01-01'
);
const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
expect(timeline).toEqual([
{

View File

@ -1,3 +1,4 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { OrderType } from '@ghostfolio/api/models/order-type';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
@ -365,16 +366,20 @@ export class PortfolioCalculator {
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string
): Promise<TimelinePeriod[]> {
): Promise<TimelineInfoInterface> {
if (timelineSpecification.length === 0) {
return [];
return {
maxNetPerformance: new Big(0),
minNetPerformance: new Big(0),
timelinePeriods: []
};
}
const startDate = timelineSpecification[0].start;
const start = parseDate(startDate);
const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelinePeriod[]>[] = [];
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
let i = 0;
let j = -1;
for (
@ -417,11 +422,40 @@ export class PortfolioCalculator {
}
}
const timelinePeriods: TimelinePeriod[][] = await Promise.all(
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises
);
const minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
return flatten(timelinePeriods);
const maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
const timelinePeriods = timelineInfoInterfaces.map(
(timelineInfo) => timelineInfo.timelinePeriods
);
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: flatten(timelinePeriods)
};
}
private calculateOverallPerformance(
@ -513,7 +547,7 @@ export class PortfolioCalculator {
j: number,
startDate: Date,
endDate: Date
): Promise<TimelinePeriod[]> {
): Promise<TimelineInfoInterface> {
let investment: Big = new Big(0);
let fees: Big = new Big(0);
@ -569,6 +603,8 @@ export class PortfolioCalculator {
}
const results: TimelinePeriod[] = [];
let maxNetPerformance: Big = null;
let minNetPerformance: Big = null;
for (
let currentDate = startDate;
isBefore(currentDate, endDate);
@ -592,18 +628,36 @@ export class PortfolioCalculator {
}
if (!invalid) {
const grossPerformance = value.minus(investment);
const netPerformance = grossPerformance.minus(fees);
if (
minNetPerformance === null ||
minNetPerformance.gt(netPerformance)
) {
minNetPerformance = netPerformance;
}
if (
maxNetPerformance === null ||
maxNetPerformance.lt(netPerformance)
) {
maxNetPerformance = netPerformance;
}
const result = {
grossPerformance,
investment,
netPerformance,
value,
date: currentDateAsString,
netPerformance: grossPerformance.minus(fees)
date: currentDateAsString
};
results.push(result);
}
}
return results;
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: results
};
}
private getFactor(type: OrderType) {

View File

@ -8,6 +8,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config';
import {
PortfolioChart,
PortfolioDetails,
PortfolioPerformance,
PortfolioPublicDetails,
@ -32,10 +33,7 @@ import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import {
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service';
@ -92,12 +90,14 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId,
@Query('range') range,
@Res() res: Response
): Promise<HistoricalDataItem[]> {
let chartData = await this.portfolioService.getChart(
): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioService.getChart(
impersonationId,
range
);
let chartData = historicalDataContainer.items;
let hasNullValue = false;
chartData.forEach((chartDataItem) => {
@ -130,7 +130,11 @@ export class PortfolioController {
});
}
return <any>res.json(chartData);
return <any>res.json({
chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow
});
}
@Get('details')

View File

@ -28,6 +28,7 @@ import {
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
PortfolioDetails,
PortfolioPerformance,
PortfolioReport,
@ -55,12 +56,14 @@ import {
parse,
parseISO,
setDayOfYear,
startOfDay,
subDays,
subYears
} from 'date-fns';
import { isEmpty } from 'lodash';
import {
HistoricalDataContainer,
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
@ -93,15 +96,23 @@ export class PortfolioService {
const userCurrency = this.request.user.Settings.currency;
return accounts.map((account) => {
let transactionCount = 0;
for (const order of account.Order) {
if (!order.isDraft) {
transactionCount += 1;
}
}
const result = {
...account,
transactionCount,
convertedBalance: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
transactionCount: account.Order.length,
value: details.accounts[account.name].current
value: details.accounts[account.name]?.current ?? 0
};
delete result.Order;
@ -110,6 +121,21 @@ export class PortfolioService {
});
}
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId);
let totalBalance = 0;
let totalValue = 0;
let transactionCount = 0;
for (const account of accounts) {
totalBalance += account.convertedBalance;
totalValue += account.value;
transactionCount += account.transactionCount;
}
return { accounts, totalBalance, totalValue, transactionCount };
}
public async getInvestments(
aImpersonationId: string
): Promise<InvestmentItem[]> {
@ -140,7 +166,7 @@ export class PortfolioService {
public async getChart(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> {
): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
@ -151,14 +177,21 @@ export class PortfolioService {
const { transactionPoints } = await this.getTransactionPoints({ userId });
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return [];
return {
isAllTimeHigh: false,
isAllTimeLow: false,
items: []
};
}
let portfolioStart = parse(
transactionPoints[0].date,
DATE_FORMAT,
new Date()
);
portfolioStart = this.getStartDate(aDateRange, portfolioStart);
// Get start date for the full portfolio because of because of the
// min and max calculation
portfolioStart = this.getStartDate('max', portfolioStart);
const timelineSpecification: TimelineSpecification[] = [
{
@ -167,18 +200,52 @@ export class PortfolioService {
}
];
const timeline = await portfolioCalculator.calculateTimeline(
const timelineInfo = await portfolioCalculator.calculateTimeline(
timelineSpecification,
format(new Date(), DATE_FORMAT)
);
return timeline
const timeline = timelineInfo.timelinePeriods;
const items = timeline
.filter((timelineItem) => timelineItem !== null)
.map((timelineItem) => ({
date: timelineItem.date,
marketPrice: timelineItem.value,
value: timelineItem.netPerformance.toNumber()
}));
let lastItem = null;
if (timeline.length > 0) {
lastItem = timeline[timeline.length - 1];
}
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
lastItem?.netPerformance
);
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
lastItem?.netPerformance
);
if (isAllTimeHigh && isAllTimeLow) {
isAllTimeHigh = false;
isAllTimeLow = false;
}
portfolioStart = startOfDay(
this.getStartDate(
aDateRange,
parse(transactionPoints[0].date, DATE_FORMAT, new Date())
)
);
return {
isAllTimeHigh,
isAllTimeLow,
items: items.filter((item) => {
// Filter items of date range
return !isAfter(portfolioStart, parseDate(item.date));
})
};
}
public async getDetails(
@ -615,7 +682,9 @@ export class PortfolioService {
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0
currentValue: 0,
isAllTimeHigh: false,
isAllTimeLow: false
}
};
}
@ -648,7 +717,9 @@ export class PortfolioService {
currentGrossPerformancePercent,
currentNetPerformance,
currentNetPerformancePercent,
currentValue
currentValue,
isAllTimeHigh: true, // TODO
isAllTimeLow: false // TODO
}
};
}
@ -924,16 +995,9 @@ export class PortfolioService {
};
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
);
let currentValueOfSymbol =
order.quantity * portfolioItemsNow[order.symbol].marketPrice;
let originalValueOfSymbol = order.quantity * order.unitPrice;
if (order.type === 'SELL') {
currentValueOfSymbol *= -1;

View File

@ -1,5 +1,6 @@
{
"1INCH": "1inch",
"ALGO": "Algorand",
"AVAX": "Avalanche",
"MATIC": "Polygon",
"SHIB": "Shiba Inu"

View File

@ -197,16 +197,20 @@ export class YahooFinanceService implements DataProviderInterface {
// filter out undefined symbols
return quote.symbol;
})
.filter(({ quoteType }) => {
.filter(({ quoteType, symbol }) => {
return (
quoteType === 'CRYPTOCURRENCY' ||
(quoteType === 'CRYPTOCURRENCY' &&
this.cryptocurrencyService.isCrypto(
symbol.replace(new RegExp('-USD$'), 'USD').replace('1', '')
)) ||
quoteType === 'EQUITY' ||
quoteType === 'ETF'
);
})
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD
// Only allow cryptocurrencies in USD to avoid having redundancy in the database.
// Trades need to be converted manually before to USD (or a UI converter needs to be developed)
return symbol.includes('USD');
}
@ -254,14 +258,15 @@ export class YahooFinanceService implements DataProviderInterface {
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCrypto(aSymbol) ||
this.cryptocurrencyService.isCrypto(aSymbol.replace('1', ''))
this.cryptocurrencyService.isCrypto(
aSymbol.replace(new RegExp('-USD$'), 'USD').replace('1', '')
)
) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace('USD', '-USD');
return aSymbol.replace(new RegExp('-?USD$'), '-USD');
}
}

View File

@ -15,6 +15,7 @@
>(Default)</span
>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td>
</ng-container>
<ng-container matColumnDef="currency">
@ -29,6 +30,9 @@
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
{{ element.currency }}
</td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
{{ baseCurrency }}
</td>
</ng-container>
<ng-container matColumnDef="platform">
@ -51,6 +55,11 @@
<span>{{ element.Platform?.name }}</span>
</div>
</td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container>
<ng-container matColumnDef="transactions">
@ -63,6 +72,9 @@
element.transactionCount
}}</ng-container>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
{{ transactionCount }}
</td>
</ng-container>
<ng-container matColumnDef="balance">
@ -77,6 +89,14 @@
[value]="element.convertedBalance"
></gf-value>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalBalance"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="value">
@ -91,6 +111,14 @@
[value]="element.value"
></gf-value>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalValue"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="actions">
@ -118,10 +146,16 @@
</button>
</mat-menu>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
<tr
*matFooterRowDef="displayedColumns"
mat-footer-row
[ngClass]="{ 'd-none': isLoading }"
></tr>
</table>
<ngx-skeleton-loader

View File

@ -10,6 +10,16 @@
}
.mat-table {
td {
&.mat-footer-cell {
border-top: 1px solid
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
}
}
th {
::ng-deep {
.mat-sort-header-container {
@ -21,7 +31,14 @@
}
:host-context(.is-dark-theme) {
.mat-form-field {
color: rgba(var(--light-primary-text));
.mat-table {
td {
&.mat-footer-cell {
border-top-color: rgba(
var(--palette-foreground-divider-dark),
var(--palette-foreground-divider-dark-alpha)
);
}
}
}
}

View File

@ -24,6 +24,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() deviceType: string;
@Input() locale: string;
@Input() showActions: boolean;
@Input() totalBalance: number;
@Input() totalValue: number;
@Input() transactionCount: number;
@Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>();

View File

@ -270,6 +270,7 @@
Sign In
</button>
<a
*ngIf="currentRoute !== 'register'"
class="d-none d-sm-block"
color="primary"
i18n

View File

@ -1,5 +1,11 @@
<div class="container p-0">
<div class="row no-gutters">
<div
class="no-gutters row"
[ngClass]="{
'text-danger': isAllTimeLow,
'text-success': isAllTimeHigh
}"
>
<div class="flex-grow-1"></div>
<div *ngIf="isLoading" class="align-items-center d-flex">
<ngx-skeleton-loader
@ -12,8 +18,8 @@
></ngx-skeleton-loader>
</div>
<div
[hidden]="isLoading"
class="display-4 font-weight-bold m-0 text-center value-container"
[hidden]="isLoading"
>
<span #value id="value"></span>
</div>

View File

@ -19,6 +19,8 @@ import { isNumber } from 'lodash';
})
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() baseCurrency: string;
@Input() isAllTimeHigh: boolean;
@Input() isAllTimeLow: boolean;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() performance: PortfolioPerformance;

View File

@ -77,7 +77,7 @@
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>
Fees for {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1
{order} other {orders}}
{transaction} other {transactions}}
</div>
<div class="d-flex justify-content-end">
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
@ -132,7 +132,7 @@
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Cash</div>
<div class="d-flex flex-grow-1" i18n>Cash (Buying Power)</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"

View File

@ -15,6 +15,27 @@
</td>
</ng-container>
<ng-container matColumnDef="value">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Value
</th>
<td class="d-none d-lg-table-cell px-1" mat-cell *matCellDef="let element">
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.value"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="performance">
<th
*matHeaderCellDef

View File

@ -70,6 +70,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnChanges() {
this.displayedColumns = [
'symbol',
'value',
'performance',
'allocationInvestment',
'allocationCurrent'

View File

@ -28,6 +28,9 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
public hasPermissionToCreateAccount: boolean;
public hasPermissionToDeleteAccount: boolean;
public routeQueryParams: Subscription;
public totalBalance = 0;
public totalValue = 0;
public transactionCount = 0;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -103,8 +106,11 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
this.dataService
.fetchAccounts()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.accounts = response;
.subscribe(({ accounts, totalBalance, totalValue, transactionCount }) => {
this.accounts = accounts;
this.totalBalance = totalBalance;
this.totalValue = totalValue;
this.transactionCount = transactionCount;
if (this.accounts?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });

View File

@ -8,6 +8,9 @@
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView"
[totalBalance]="totalBalance"
[totalValue]="totalValue"
[transactionCount]="transactionCount"
(accountDeleted)="onDeleteAccount($event)"
(accountToUpdate)="onUpdateAccount($event)"
></gf-accounts-table>

View File

@ -59,6 +59,8 @@ export class HomePageComponent implements OnDestroy, OnInit {
public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateOrder: boolean;
public historicalDataItems: LineChartItem[];
public isAllTimeHigh: boolean;
public isAllTimeLow: boolean;
public isLoadingPerformance = true;
public isLoadingSummary = true;
public performance: PortfolioPerformance;
@ -166,12 +168,14 @@ export class HomePageComponent implements OnDestroy, OnInit {
.fetchChart({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((chartData) => {
this.historicalDataItems = chartData.map((chartDataItem) => {
this.historicalDataItems = chartData.chart.map((chartDataItem) => {
return {
date: chartDataItem.date,
value: chartDataItem.value
};
});
this.isAllTimeHigh = chartData.isAllTimeHigh;
this.isAllTimeLow = chartData.isAllTimeLow;
this.changeDetectorRef.markForCheck();
});

View File

@ -54,6 +54,8 @@
<gf-portfolio-performance
class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale"
[performance]="performance"

View File

@ -27,7 +27,6 @@
&.overview {
.chart-container {
aspect-ratio: 16 / 9;
cursor: pointer;
max-height: 50vh;
// Fallback for aspect-ratio (using padding hack)

View File

@ -14,6 +14,28 @@ import { Subject } from 'rxjs';
export class LandingPageComponent implements OnDestroy, OnInit {
public currentYear = format(new Date(), 'yyyy');
public demoAuthToken: string;
public testimonials = [
{
author: 'Philipp',
country: 'Germany 🇩🇪',
quote: `Super slim app with a great user interface. On top of that, it's open source.`
},
{
author: 'Onur',
country: 'Switzerland 🇨🇭',
quote: `Ghostfolio looks like the perfect portfolio tracker I've been searching for all these years.`
},
{
author: 'Ivo',
country: 'Netherlands 🇳🇱',
quote: `A fantastic open source app to track my investments across platforms. Love the simplicity of its design and the depth of the insights.`
},
{
author: 'Damjan',
country: 'Slovenia 🇸🇮',
quote: `Ghostfolio helps me track all my investments in one place, it has a built-in portfolio analyzer and a very neat, seamless user interface.`
}
];
private unsubscribeSubject = new Subject<void>();

View File

@ -107,6 +107,31 @@
</div>
</div>
<div class="row my-5">
<div class="col-12">
<h2 class="h4 mb-1 text-center">
What our <strong>users</strong> are saying
</h2>
</div>
<div *ngFor="let testimonial of testimonials" class="col-md-6">
<div class="d-flex flex-row py-3">
<div class="d-flex justify-content-center">
<gf-logo
class="mr-3 mt-2 pt-1"
size="medium"
[hideName]="true"
></gf-logo>
</div>
<div>
<div>{{ testimonial.quote }}</div>
<div class="mt-2 text-muted">
— {{ testimonial.author }}, {{ testimonial.country }}
</div>
</div>
</div>
</div>
</div>
<div class="row my-5">
<div class="col-md-6 offset-md-3">
<h2 class="h4 mb-1 text-center">

View File

@ -84,6 +84,10 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
})
);
if (this.data.transaction.id) {
this.searchSymbolCtrl.disable();
}
if (this.data.transaction.symbol) {
this.dataService
.fetchSymbolItem({

View File

@ -10,9 +10,7 @@
required
[(value)]="data.transaction.accountId"
>
<mat-option
*ngFor="let account of data.user?.accounts"
[value]="account.id"
<mat-option *ngFor="let account of data.accounts" [value]="account.id"
>{{ account.name }}</mat-option
>
</mat-select>

View File

@ -1,8 +1,9 @@
import { User } from '@ghostfolio/common/interfaces';
import { Order } from '@prisma/client';
import { Account, Order } from '@prisma/client';
export interface CreateOrUpdateTransactionDialogParams {
accountId: string;
accounts: Account[];
transaction: Order;
user: User;
}

View File

@ -261,6 +261,9 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
}: OrderModel): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: {
accounts: this.user?.accounts?.filter((account) => {
return account.accountType === 'SECURITIES';
}),
transaction: {
accountId,
currency,
@ -343,6 +346,9 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
private openCreateTransactionDialog(aTransaction?: OrderModel): void {
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: {
accounts: this.user?.accounts?.filter((account) => {
return account.accountType === 'SECURITIES';
}),
transaction: {
accountId: aTransaction?.accountId ?? this.defaultAccountId,
currency: aTransaction?.currency ?? null,

View File

@ -39,6 +39,8 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public historicalDataItems: LineChartItem[];
public isAllTimeHigh: boolean;
public isAllTimeLow: boolean;
public isLoadingPerformance = true;
public performance: PortfolioPerformance;
public positions: Position[];
@ -114,12 +116,14 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
.fetchChart({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((chartData) => {
this.historicalDataItems = chartData.map((chartDataItem) => {
this.historicalDataItems = chartData.chart.map((chartDataItem) => {
return {
date: chartDataItem.date,
value: chartDataItem.value
};
});
this.isAllTimeHigh = chartData.isAllTimeHigh;
this.isAllTimeLow = chartData.isAllTimeLow;
this.changeDetectorRef.markForCheck();
});

View File

@ -47,6 +47,8 @@
<gf-portfolio-performance
class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale"
[performance]="performance"

View File

@ -5,10 +5,7 @@ import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import {
HistoricalDataItem,
PortfolioPositionDetail
} from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { PortfolioPositionDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
@ -17,9 +14,11 @@ import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setti
import { UpdateUserSettingsDto } from '@ghostfolio/api/app/user/update-user-settings.dto';
import {
Access,
Accounts,
AdminData,
Export,
InfoItem,
PortfolioChart,
PortfolioDetails,
PortfolioPerformance,
PortfolioPublicDetails,
@ -29,12 +28,8 @@ import {
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { permissions } from '@ghostfolio/common/permissions';
import { AccountWithValue, DateRange } from '@ghostfolio/common/types';
import {
Account as AccountModel,
DataSource,
Order as OrderModel
} from '@prisma/client';
import { DateRange } from '@ghostfolio/common/types';
import { DataSource, Order as OrderModel } from '@prisma/client';
import { parseISO } from 'date-fns';
import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs';
@ -62,7 +57,7 @@ export class DataService {
}
public fetchAccounts() {
return this.http.get<AccountWithValue[]>('/api/account');
return this.http.get<Accounts>('/api/account');
}
public fetchAdminData() {
@ -90,7 +85,7 @@ export class DataService {
}
public fetchChart({ range }: { range: DateRange }) {
return this.http.get<HistoricalDataItem[]>('/api/portfolio/chart', {
return this.http.get<PortfolioChart>('/api/portfolio/chart', {
params: { range }
});
}

View File

@ -1,8 +1,12 @@
@mixin gf-table($darkTheme: false) {
background: transparent !important;
td {
border: 0 !important;
.mat-footer-row,
.mat-row {
.mat-cell,
.mat-footer-cell {
border-bottom: 0;
}
}
.mat-row {

View File

@ -0,0 +1,8 @@
import { AccountWithValue } from '@ghostfolio/common/types';
export interface Accounts {
accounts: AccountWithValue[];
totalBalance: number;
totalValue: number;
transactionCount: number;
}

View File

@ -1,7 +1,9 @@
import { Access } from './access.interface';
import { Accounts } from './accounts.interface';
import { AdminData } from './admin-data.interface';
import { Export } from './export.interface';
import { InfoItem } from './info-item.interface';
import { PortfolioChart } from './portfolio-chart.interface';
import { PortfolioDetails } from './portfolio-details.interface';
import { PortfolioItem } from './portfolio-item.interface';
import { PortfolioOverview } from './portfolio-overview.interface';
@ -19,9 +21,11 @@ import { User } from './user.interface';
export {
Access,
Accounts,
AdminData,
Export,
InfoItem,
PortfolioChart,
PortfolioDetails,
PortfolioItem,
PortfolioOverview,

View File

@ -0,0 +1,7 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
export interface PortfolioChart {
isAllTimeHigh: boolean;
isAllTimeLow: boolean;
chart: HistoricalDataItem[];
}

View File

@ -5,4 +5,6 @@ export interface PortfolioPerformance {
currentNetPerformance: number;
currentNetPerformancePercent: number;
currentValue: number;
isAllTimeHigh: boolean;
isAllTimeLow: boolean;
}

View File

@ -2,5 +2,6 @@ import { Account as AccountModel } from '@prisma/client';
export type AccountWithValue = AccountModel & {
convertedBalance: number;
transactionCount: number;
value: number;
};

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.75.0",
"version": "1.80.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {