Compare commits
9 Commits
Author | SHA1 | Date | |
---|---|---|---|
6a0cfb8f77 | |||
6386786ac0 | |||
d3be6577c8 | |||
73a967a7e5 | |||
836ff6ec13 | |||
c5bb3023d3 | |||
695c378b48 | |||
fe975945d1 | |||
d8782b0d4c |
19
CHANGELOG.md
19
CHANGELOG.md
@ -5,6 +5,25 @@ 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/),
|
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).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 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
|
## 1.42.0 - 22.08.2021
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -62,7 +62,7 @@ Ghostfolio is for you if you are...
|
|||||||
|
|
||||||
- ✅ Create, update and delete transactions
|
- ✅ Create, update and delete transactions
|
||||||
- ✅ Multi account management
|
- ✅ 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
|
- ✅ Various charts
|
||||||
- ✅ Static analysis to identify potential risks in your portfolio
|
- ✅ Static analysis to identify potential risks in your portfolio
|
||||||
- ✅ Dark Mode
|
- ✅ Dark Mode
|
||||||
|
@ -211,6 +211,11 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of currentPositions.positions) {
|
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 value = item.quantity.mul(item.marketPrice);
|
||||||
const symbolProfile = symbolProfileMap[item.symbol];
|
const symbolProfile = symbolProfileMap[item.symbol];
|
||||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||||
@ -719,6 +724,7 @@ export class PortfolioService {
|
|||||||
allocationCurrent: cashValue.div(value).toNumber(),
|
allocationCurrent: cashValue.div(value).toNumber(),
|
||||||
allocationInvestment: cashValue.div(investment).toNumber(),
|
allocationInvestment: cashValue.div(investment).toNumber(),
|
||||||
assetClass: AssetClass.CASH,
|
assetClass: AssetClass.CASH,
|
||||||
|
assetSubClass: AssetClass.CASH,
|
||||||
countries: [],
|
countries: [],
|
||||||
currency: Currency.CHF,
|
currency: Currency.CHF,
|
||||||
grossPerformance: 0,
|
grossPerformance: 0,
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||||
@ -48,6 +49,15 @@ export class SymbolController {
|
|||||||
@Get(':symbol')
|
@Get(':symbol')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> {
|
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> {
|
public async get(aSymbol: string): Promise<SymbolItem> {
|
||||||
const response = await this.dataProviderService.get([aSymbol]);
|
const response = await this.dataProviderService.get([aSymbol]);
|
||||||
const { currency, dataSource, marketPrice } = response[aSymbol];
|
const { currency, dataSource, marketPrice } = response[aSymbol] ?? {};
|
||||||
|
|
||||||
return {
|
if (currency && dataSource && marketPrice) {
|
||||||
dataSource,
|
return {
|
||||||
marketPrice,
|
dataSource,
|
||||||
currency: <Currency>(<unknown>currency)
|
marketPrice,
|
||||||
};
|
currency: <Currency>(<unknown>currency)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||||
|
@ -136,13 +136,14 @@ export class DataGatheringService {
|
|||||||
|
|
||||||
for (const [
|
for (const [
|
||||||
symbol,
|
symbol,
|
||||||
{ assetClass, assetSubClass, currency, dataSource, name }
|
{ assetClass, assetSubClass, countries, currency, dataSource, name }
|
||||||
] of Object.entries(currentData)) {
|
] of Object.entries(currentData)) {
|
||||||
try {
|
try {
|
||||||
await this.prismaService.symbolProfile.upsert({
|
await this.prismaService.symbolProfile.upsert({
|
||||||
create: {
|
create: {
|
||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
|
countries,
|
||||||
currency,
|
currency,
|
||||||
dataSource,
|
dataSource,
|
||||||
name,
|
name,
|
||||||
@ -151,6 +152,7 @@ export class DataGatheringService {
|
|||||||
update: {
|
update: {
|
||||||
assetClass,
|
assetClass,
|
||||||
assetSubClass,
|
assetSubClass,
|
||||||
|
countries,
|
||||||
currency,
|
currency,
|
||||||
name
|
name
|
||||||
},
|
},
|
||||||
|
@ -25,6 +25,7 @@ export interface IYahooFinancePrice {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface IYahooFinanceSummaryProfile {
|
export interface IYahooFinanceSummaryProfile {
|
||||||
|
country?: string;
|
||||||
industry?: string;
|
industry?: string;
|
||||||
sector?: string;
|
sector?: string;
|
||||||
website?: string;
|
website?: string;
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
|
import { countries } from 'countries-list';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import * as yahooFinance from 'yahoo-finance';
|
import * as yahooFinance from 'yahoo-finance';
|
||||||
|
|
||||||
@ -92,6 +93,23 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
.toNumber();
|
.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;
|
const url = value.summaryProfile?.website;
|
||||||
if (url) {
|
if (url) {
|
||||||
response[symbol].url = url;
|
response[symbol].url = url;
|
||||||
|
@ -3,7 +3,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { isNumber } from 'lodash';
|
import { isEmpty, isNumber } from 'lodash';
|
||||||
|
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
|
|
||||||
@ -35,6 +35,24 @@ export class ExchangeRateDataService {
|
|||||||
getYesterday()
|
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;
|
const resultExtended = result;
|
||||||
|
|
||||||
Object.keys(result).forEach((pair) => {
|
Object.keys(result).forEach((pair) => {
|
||||||
|
@ -37,6 +37,7 @@ export interface IDataProviderHistoricalResponse {
|
|||||||
export interface IDataProviderResponse {
|
export interface IDataProviderResponse {
|
||||||
assetClass?: AssetClass;
|
assetClass?: AssetClass;
|
||||||
assetSubClass?: AssetSubClass;
|
assetSubClass?: AssetSubClass;
|
||||||
|
countries?: { code: string; weight: number }[];
|
||||||
currency: Currency;
|
currency: Currency;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
exchange?: string;
|
exchange?: string;
|
||||||
|
@ -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 { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
import { Currency } from '@prisma/client';
|
import { Currency } from '@prisma/client';
|
||||||
import { Observable, Subject } from 'rxjs';
|
import { EMPTY, Observable, Subject } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
|
catchError,
|
||||||
debounceTime,
|
debounceTime,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
startWith,
|
startWith,
|
||||||
@ -49,7 +50,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
|||||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams
|
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
ngOnInit() {
|
public ngOnInit() {
|
||||||
const { currencies, platforms } = this.dataService.fetchInfo();
|
const { currencies, platforms } = this.dataService.fetchInfo();
|
||||||
|
|
||||||
this.currencies = currencies;
|
this.currencies = currencies;
|
||||||
@ -84,17 +85,45 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
|||||||
this.data.transaction.unitPrice = this.currentMarketPrice;
|
this.data.transaction.unitPrice = this.currentMarketPrice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public onBlurSymbol() {
|
||||||
|
const symbol = this.searchSymbolCtrl.value;
|
||||||
|
this.updateSymbol(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
public onCancel(): void {
|
public onCancel(): void {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
|
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.isLoading = true;
|
||||||
this.data.transaction.symbol = event.option.value;
|
|
||||||
|
this.data.transaction.symbol = symbol;
|
||||||
|
|
||||||
this.dataService
|
this.dataService
|
||||||
.fetchSymbolItem(this.data.transaction.symbol)
|
.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 }) => {
|
.subscribe(({ currency, dataSource, marketPrice }) => {
|
||||||
this.data.transaction.currency = currency;
|
this.data.transaction.currency = currency;
|
||||||
this.data.transaction.dataSource = dataSource;
|
this.data.transaction.dataSource = dataSource;
|
||||||
@ -105,17 +134,4 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
|||||||
this.changeDetectorRef.markForCheck();
|
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
|
required
|
||||||
[formControl]="searchSymbolCtrl"
|
[formControl]="searchSymbolCtrl"
|
||||||
[matAutocomplete]="auto"
|
[matAutocomplete]="auto"
|
||||||
(change)="onUpdateSymbolByTyping($event.target.value)"
|
(blur)="onBlurSymbol()"
|
||||||
/>
|
/>
|
||||||
<mat-autocomplete
|
<mat-autocomplete
|
||||||
#auto="matAutocomplete"
|
#auto="matAutocomplete"
|
||||||
|
@ -8,7 +8,7 @@ export interface PortfolioPosition {
|
|||||||
allocationCurrent: number;
|
allocationCurrent: number;
|
||||||
allocationInvestment: number;
|
allocationInvestment: number;
|
||||||
assetClass?: AssetClass;
|
assetClass?: AssetClass;
|
||||||
assetSubClass?: AssetSubClass;
|
assetSubClass?: AssetSubClass | 'CASH';
|
||||||
countries: Country[];
|
countries: Country[];
|
||||||
currency: Currency;
|
currency: Currency;
|
||||||
exchange?: string;
|
exchange?: string;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "ghostfolio",
|
"name": "ghostfolio",
|
||||||
"version": "1.42.0",
|
"version": "1.44.0",
|
||||||
"homepage": "https://ghostfol.io",
|
"homepage": "https://ghostfol.io",
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -103,7 +103,7 @@
|
|||||||
"round-to": "5.0.0",
|
"round-to": "5.0.0",
|
||||||
"rxjs": "6.6.7",
|
"rxjs": "6.6.7",
|
||||||
"stripe": "8.156.0",
|
"stripe": "8.156.0",
|
||||||
"svgmap": "2.1.1",
|
"svgmap": "2.6.0",
|
||||||
"uuid": "8.3.2",
|
"uuid": "8.3.2",
|
||||||
"yahoo-finance": "0.3.6",
|
"yahoo-finance": "0.3.6",
|
||||||
"zone.js": "0.11.4"
|
"zone.js": "0.11.4"
|
||||||
|
@ -12991,10 +12991,10 @@ svg-pan-zoom@^3.6.1:
|
|||||||
resolved "https://registry.yarnpkg.com/svg-pan-zoom/-/svg-pan-zoom-3.6.1.tgz#f880a1bb32d18e9c625d7715350bebc269b450cf"
|
resolved "https://registry.yarnpkg.com/svg-pan-zoom/-/svg-pan-zoom-3.6.1.tgz#f880a1bb32d18e9c625d7715350bebc269b450cf"
|
||||||
integrity sha512-JaKkGHHfGvRrcMPdJWkssLBeWqM+Isg/a09H7kgNNajT1cX5AztDTNs+C8UzpCxjCTRrG34WbquwaovZbmSk9g==
|
integrity sha512-JaKkGHHfGvRrcMPdJWkssLBeWqM+Isg/a09H7kgNNajT1cX5AztDTNs+C8UzpCxjCTRrG34WbquwaovZbmSk9g==
|
||||||
|
|
||||||
svgmap@2.1.1:
|
svgmap@2.6.0:
|
||||||
version "2.1.1"
|
version "2.6.0"
|
||||||
resolved "https://registry.yarnpkg.com/svgmap/-/svgmap-2.1.1.tgz#355c259cf4e04b20d2d39bab05d0e718ade942ff"
|
resolved "https://registry.yarnpkg.com/svgmap/-/svgmap-2.6.0.tgz#8533f40d3c1015d25f5e799e677b794acdd5fbc5"
|
||||||
integrity sha512-1blZYMYDXq8H3xykzgBJRh5q+XPd5JLOJ8K7UuZI6ab2D3hngiVcr+Z1olfy7DH9Xf9AOCTpt4Id7iVD8cKD0A==
|
integrity sha512-MePkVjgYlHwEfCSuGt+wB6WX6Z2fTD6yDtqZO5syzKYH7gamt1Hp9f/Bw5R49OvBtbsF7WCaGR0/GhewZGGA+w==
|
||||||
dependencies:
|
dependencies:
|
||||||
svg-pan-zoom "^3.6.1"
|
svg-pan-zoom "^3.6.1"
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user