Compare commits

...

8 Commits

Author SHA1 Message Date
598fe41b8c Release 1.137.0 (#831) 2022-04-15 18:58:33 +02:00
ba7c98d325 Add test case for BUY and SELL (partially) (#826)
* Add test case for BUY and SELL (partially)

* Fix investment calculation for sell activities

* Do not show total value if sell activity

* Update changelog
2022-04-15 18:56:23 +02:00
65e062ad26 Simplify search (#828)
* Simplify search

* Update changelog
2022-04-15 12:39:33 +02:00
8526b5a027 Feature/export draft activities as ics (#830)
* Export draft activities as ICS

* Update changelog
2022-04-15 10:53:40 +02:00
f1feb04f29 Release 1.136.0 (#829) 2022-04-13 07:46:35 +02:00
500e09d95a Bugfix/fix loading state in fire calculator (#824)
* Fix loading state

* Update changelog
2022-04-12 19:57:23 +02:00
aef91d3e30 Feature/improve label in summary (#827)
* Improve label

* Update changelog
2022-04-12 18:04:48 +02:00
70723f8d5f Bugfix/fix projected total amount in fire calculator (#825)
* Fix calculation of projected total amount

* Update changelog
2022-04-11 22:10:45 +02:00
18 changed files with 323 additions and 53 deletions

View File

@ -5,6 +5,31 @@ 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.137.0 - 15.04.2022
### Added
- Added support to export future activities (drafts) as `.ics` files
### Changed
- Migrated the search functionality to `yahoo-finance2`
### Fixed
- Fixed an issue in the average price / investment calculation for sell activities
## 1.136.0 - 13.04.2022
### Changed
- Changed the _Total_ label to _Total Assets_ in the portfolio summary tab on the home page
### Fixed
- Fixed an issue with the calculation of the projected total amount in the _FIRE_ calculator
- Fixed an issue with the loading state of the _FIRE_ calculator
## 1.135.0 - 10.04.2022
### Added

View File

@ -42,6 +42,7 @@ export class ExportService {
accountId,
date,
fee,
id,
quantity,
SymbolProfile,
type,
@ -49,13 +50,14 @@ export class ExportService {
}) => {
return {
accountId,
date,
fee,
id,
quantity,
type,
unitPrice,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
date: date.toISOString(),
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
};
}

View File

@ -20,6 +20,13 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 };
case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 };
}
return { marketPrice: 0 };
default:
return { marketPrice: 0 };
}

View File

@ -0,0 +1,96 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(1.3),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(75.8)
},
{
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(2.95),
name: 'Novartis AG',
quantity: new Big(1),
symbol: 'NOVN.SW',
type: 'SELL',
unitPrice: new Big(85.73)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2022-03-07')
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('87.8'),
errors: [],
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
hasErrors: false,
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
positions: [
{
averagePrice: new Big('75.80'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
investment: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
marketPrice: 87.8,
quantity: new Big('1'),
symbol: 'NOVN.SW',
transactionCount: 2
}
],
totalInvestment: new Big('75.80')
});
});
});
});

View File

@ -77,17 +77,30 @@ export class PortfolioCalculator {
const newQuantity = order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
let investment = new Big(0);
if (newQuantity.gt(0)) {
if (order.type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus(
order.quantity.mul(unitPrice)
);
} else if (order.type === 'SELL') {
const averagePrice = oldAccumulatedSymbol.investment.div(
oldAccumulatedSymbol.quantity
);
investment = oldAccumulatedSymbol.investment.minus(
order.quantity.mul(averagePrice)
);
}
}
currentTransactionPointItem = {
investment,
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
? new Big(0)
: unitPrice
.mul(order.quantity)
.mul(factor)
.plus(oldAccumulatedSymbol.investment),
quantity: newQuantity,
symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1

View File

@ -16,7 +16,6 @@ import {
DataSource,
SymbolProfile
} from '@prisma/client';
import * as bent from 'bent';
import Big from 'big.js';
import { countries } from 'countries-list';
import { addDays, format, isSameDay } from 'date-fns';
@ -25,8 +24,6 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
private readonly yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor(
private readonly cryptocurrencyService: CryptocurrencyService
) {}
@ -244,16 +241,7 @@ export class YahooFinanceService implements DataProviderInterface {
const items: LookupItem[] = [];
try {
const get = bent(
`${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
aQuery
)}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
const searchResult = await get();
const searchResult = await yahooFinance.search(aQuery);
const quotes = searchResult.quotes
.filter((quote) => {
@ -279,20 +267,24 @@ export class YahooFinanceService implements DataProviderInterface {
return true;
});
const marketData = await this.getQuotes(
const marketData = await yahooFinance.quote(
quotes.map(({ symbol }) => {
return symbol;
})
);
for (const [symbol, value] of Object.entries(marketData)) {
const quote = quotes.find((currentQuote: any) => {
return currentQuote.symbol === symbol;
for (const marketDataItem of marketData) {
const quote = quotes.find((currentQuote) => {
return currentQuote.symbol === marketDataItem.symbol;
});
const symbol = this.convertFromYahooFinanceSymbol(
marketDataItem.symbol
);
items.push({
symbol,
currency: value.currency,
currency: marketDataItem.currency,
dataSource: this.getName(),
name: quote?.longname || quote?.shortname || symbol
});

View File

@ -119,7 +119,7 @@
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Total</div>
<div class="d-flex flex-grow-1" i18n>Total Assets</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"

View File

@ -211,14 +211,14 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile(
data,
`ghostfolio-export-${this.SymbolProfile?.symbol}-${format(
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${this.SymbolProfile?.symbol}-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
'text/plain'
);
format: 'json'
});
});
}

View File

@ -7,6 +7,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -50,6 +51,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private icsService: IcsService,
private impersonationStorageService: ImpersonationStorageService,
private importTransactionsService: ImportTransactionsService,
private route: ActivatedRoute,
@ -152,14 +154,36 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile(
data,
`ghostfolio-export-${format(
for (const activity of data.activities) {
delete activity.id;
}
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
'text/plain'
);
format: 'json'
});
});
}
public onExportDrafts(activityIds?: string[]) {
this.dataService
.fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({
content: this.icsService.transformActivitiesToIcsContent(
data.activities
),
fileName: `ghostfolio-drafts-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.ics`,
format: 'string'
});
});
}

View File

@ -15,6 +15,7 @@
(activityToClone)="onCloneTransaction($event)"
(activityToUpdate)="onUpdateTransaction($event)"
(export)="onExport($event)"
(exportDrafts)="onExportDrafts($event)"
(import)="onImport()"
></gf-activities-table>
</div>

View File

@ -0,0 +1,59 @@
import { Injectable } from '@angular/core';
import { capitalize } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { Type } from '@prisma/client';
import { format, parseISO } from 'date-fns';
@Injectable({
providedIn: 'root'
})
export class IcsService {
private readonly ICS_DATE_FORMAT = 'yyyyMMdd';
public constructor() {}
public transformActivitiesToIcsContent(
aActivities: Export['activities']
): string {
const header = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Ghostfolio//NONSGML v1.0//EN'
];
const events = aActivities.map((activity) => {
return this.getEvent({
date: parseISO(activity.date),
id: activity.id,
symbol: activity.symbol,
type: activity.type
});
});
const footer = ['END:VCALENDAR'];
return [...header, ...events, ...footer].join('\n');
}
private getEvent({
date,
id,
symbol,
type
}: {
date: Date;
id: string;
symbol: string;
type: Type;
}) {
const today = format(new Date(), this.ICS_DATE_FORMAT);
return [
'BEGIN:VEVENT',
`UID:${id}`,
`DTSTAMP:${today}T000000`,
`DTSTART;VALUE=DATE:${format(date, this.ICS_DATE_FORMAT)}`,
`DTEND;VALUE=DATE:${format(date, this.ICS_DATE_FORMAT)}`,
`SUMMARY:${capitalize(type)} ${symbol}`,
'END:VEVENT'
].join('\n');
}
}

View File

@ -12,17 +12,28 @@ export function decodeDataSource(encodedDataSource: string) {
return Buffer.from(encodedDataSource, 'hex').toString();
}
export function downloadAsFile(
aContent: unknown,
aFileName: string,
aContentType: string
) {
export function downloadAsFile({
content,
contentType = 'text/plain',
fileName,
format
}: {
content: unknown;
contentType?: string;
fileName: string;
format: 'json' | 'string';
}) {
const a = document.createElement('a');
const file = new Blob([JSON.stringify(aContent, undefined, ' ')], {
type: aContentType
if (format === 'json') {
content = JSON.stringify(content, undefined, ' ');
}
const file = new Blob([<string>content], {
type: contentType
});
a.href = URL.createObjectURL(file);
a.download = aFileName;
a.download = fileName;
a.click();
}

View File

@ -5,5 +5,14 @@ export interface Export {
date: string;
version: string;
};
activities: Partial<Order>[];
activities: (Omit<
Order,
| 'accountUserId'
| 'createdAt'
| 'date'
| 'isDraft'
| 'symbolProfileId'
| 'updatedAt'
| 'userId'
> & { date: string; symbol: string })[];
}

View File

@ -271,6 +271,7 @@
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
<div class="d-flex justify-content-end">
<gf-value
*ngIf="totalValue !== null"
[isAbsolute]="true"
[isCurrency]="true"
[locale]="locale"
@ -302,6 +303,7 @@
<td *matFooterCellDef class="d-lg-none d-xl-none px-1" mat-footer-cell>
<div class="d-flex justify-content-end">
<gf-value
*ngIf="totalValue !== null"
[isAbsolute]="true"
[isCurrency]="true"
[locale]="locale"
@ -356,11 +358,22 @@
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="dataSource.data.length === 0"
(click)="onExport()"
>
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
<span i18n>Export</span>
</button>
<button
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="!hasDrafts"
(click)="onExportDrafts()"
>
<ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon>
<span i18n>Export Drafts as ICS</span>
</button>
</mat-menu>
</th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>

View File

@ -56,6 +56,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Output() activityToClone = new EventEmitter<OrderWithAccount>();
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
@Output() export = new EventEmitter<string[]>();
@Output() exportDrafts = new EventEmitter<string[]>();
@Output() import = new EventEmitter<void>();
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
@ -68,6 +69,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
public endOfToday = endOfToday();
public filters$: Subject<string[]> = new BehaviorSubject([]);
public filters: Observable<string[]> = this.filters$.asObservable();
public hasDrafts = false;
public isAfter = isAfter;
public isLoading = true;
public isUUID = isUUID;
@ -198,6 +200,18 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
}
}
public onExportDrafts() {
this.exportDrafts.emit(
this.dataSource.filteredData
.filter((activity) => {
return activity.isDraft;
})
.map((activity) => {
return activity.id;
})
);
}
public onImport() {
this.import.emit();
}
@ -234,6 +248,9 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
this.filters$.next(this.allFilters);
this.hasDrafts = this.dataSource.data.some((activity) => {
return activity.isDraft === true;
});
this.totalFees = this.getTotalFees();
this.totalValue = this.getTotalValue();
}
@ -308,7 +325,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
if (activity.type === 'BUY' || activity.type === 'ITEM') {
totalValue = totalValue.plus(activity.valueInBaseCurrency);
} else if (activity.type === 'SELL') {
totalValue = totalValue.minus(activity.valueInBaseCurrency);
return null;
}
} else {
return null;

View File

@ -7,8 +7,8 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { baseCurrency, locale } from '@ghostfolio/common/config';
import { Meta, Story, moduleMetadata } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfValueModule } from '../value';
import { GfValueModule } from '../value';
import { FireCalculatorComponent } from './fire-calculator.component';
import { FireCalculatorService } from './fire-calculator.service';

View File

@ -12,6 +12,7 @@ import {
} from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms';
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { transformTickToAbbreviation } from '@ghostfolio/common/helper';
import {
BarController,
BarElement,
@ -20,10 +21,10 @@ import {
LinearScale,
Tooltip
} from 'chart.js';
import { isNumber } from 'lodash';
import { Subject, takeUntil } from 'rxjs';
import { FireCalculatorService } from './fire-calculator.service';
import { Subject, takeUntil } from 'rxjs';
import { transformTickToAbbreviation } from '@ghostfolio/common/helper';
@Component({
selector: 'gf-fire-calculator',
@ -84,7 +85,7 @@ export class FireCalculatorComponent
}
public ngAfterViewInit() {
if (this.fireWealth >= 0) {
if (isNumber(this.fireWealth) && this.fireWealth >= 0) {
setTimeout(() => {
// Wait for the chartCanvas
this.calculatorForm.patchValue({
@ -98,7 +99,7 @@ export class FireCalculatorComponent
}
public ngOnChanges() {
if (this.fireWealth >= 0) {
if (isNumber(this.fireWealth) && this.fireWealth >= 0) {
setTimeout(() => {
// Wait for the chartCanvas
this.calculatorForm.patchValue({
@ -234,7 +235,7 @@ export class FireCalculatorComponent
datasetPrincipal.data.push(principal.toNumber());
datasetInterest.data.push(interest.toNumber());
if (period === t - 1) {
if (period === t) {
this.projectedTotalAmount = totalAmount.toNumber();
}
}

View File

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