Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
bbe9183fb0 | |||
1b03ddc586 | |||
beb12637ce | |||
20358d9105 | |||
0e4c39d145 | |||
83ebacbb06 | |||
7c58c5fb7f | |||
f3271ab1ff | |||
9f597cbff1 | |||
90efc2ac51 | |||
056b318d86 | |||
82ede2fe32 | |||
8ae041faa0 | |||
bd4608e521 | |||
0d8362ca8f | |||
638ae3f7fa | |||
6e7cf0380b | |||
ec2ecab751 | |||
598fe41b8c | |||
ba7c98d325 | |||
65e062ad26 | |||
8526b5a027 |
1
.github/FUNDING.yml
vendored
Normal file
1
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
custom: ['https://www.buymeacoffee.com/ghostfolio']
|
62
CHANGELOG.md
62
CHANGELOG.md
@ -5,6 +5,68 @@ 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.140.0 - 22.04.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for sub-labels in the value component
|
||||
- Added a symbol profile overrides model for manual adjustments
|
||||
|
||||
### Changed
|
||||
|
||||
- Reused the value component in the _Ghostfolio in Numbers_ section of the about page
|
||||
- Persisted the savings rate in the _FIRE_ calculator
|
||||
- Upgraded `yahoo-finance2` from version `2.3.0` to `2.3.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the calculation of the total value for sell and dividend activities in the create or edit transaction dialog
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.139.0 - 18.04.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the total amount to the tooltip in the chart of the _FIRE_ calculator
|
||||
|
||||
### Changed
|
||||
|
||||
- Beautified the ETF names in the symbol profile
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with changing the investment horizon in the chart of the _FIRE_ calculator
|
||||
- Fixed an issue with the end dates in the `.ics` file of the future activities (drafts) export
|
||||
- Fixed the data source of the _Fear & Greed Index_ (market mood)
|
||||
|
||||
## 1.138.0 - 16.04.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to export a single future activity (draft) as an `.ics` file
|
||||
- Added the _Boringly Getting Rich_ guide to the resources section
|
||||
|
||||
### Changed
|
||||
|
||||
- Separated the deposit and savings in the chart of the _FIRE_ calculator
|
||||
|
||||
## 1.137.0 - 15.04.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to export future activities (drafts) as an `.ics` file
|
||||
|
||||
### 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
|
||||
|
@ -246,6 +246,8 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
|
||||
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
||||
|
||||
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
|
||||
|
||||
## License
|
||||
|
||||
© 2022 [Ghostfolio](https://ghostfol.io)
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
|
@ -52,9 +52,15 @@ export class InfoService {
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
info.fearAndGreedDataSource = encodeDataSource(
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
);
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
|
||||
) {
|
||||
info.fearAndGreedDataSource = encodeDataSource(
|
||||
ghostfolioFearAndGreedIndexDataSource
|
||||
);
|
||||
} else {
|
||||
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
|
||||
|
@ -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 };
|
||||
}
|
||||
|
@ -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')
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -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
|
||||
|
@ -12,4 +12,8 @@ export class UpdateUserSettingDto {
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
locale?: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsOptional()
|
||||
savingsRate?: number;
|
||||
}
|
||||
|
@ -17,8 +17,6 @@ import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class RakutenRapidApiService implements DataProviderInterface {
|
||||
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
|
@ -16,17 +16,17 @@ 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';
|
||||
import yahooFinance from 'yahoo-finance2';
|
||||
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
|
||||
import type {
|
||||
Price,
|
||||
QuoteSummaryResult
|
||||
} from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
|
||||
|
||||
@Injectable()
|
||||
export class YahooFinanceService implements DataProviderInterface {
|
||||
private readonly yahooFinanceHostname = 'https://query1.finance.yahoo.com';
|
||||
|
||||
public constructor(
|
||||
private readonly cryptocurrencyService: CryptocurrencyService
|
||||
) {}
|
||||
@ -92,8 +92,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
response.assetSubClass = assetSubClass;
|
||||
response.currency = assetProfile.price.currency;
|
||||
response.dataSource = this.getName();
|
||||
response.name =
|
||||
assetProfile.price.longName || assetProfile.price.shortName || symbol;
|
||||
response.name = this.formatName(assetProfile);
|
||||
response.symbol = aSymbol;
|
||||
|
||||
if (
|
||||
@ -244,16 +243,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
const items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
|
||||
aQuery
|
||||
)}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=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 +269,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
|
||||
});
|
||||
@ -304,6 +298,25 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return { items };
|
||||
}
|
||||
|
||||
private formatName(aAssetProfile: QuoteSummaryResult) {
|
||||
let name = aAssetProfile.price.longName;
|
||||
|
||||
if (name) {
|
||||
name = name.replace('iShares ETF (CH) - ', '');
|
||||
name = name.replace('iShares III Public Limited Company - ', '');
|
||||
name = name.replace('iShares VI Public Limited Company - ', '');
|
||||
name = name.replace('iShares VII PLC - ', '');
|
||||
name = name.replace('Multi Units Luxembourg - ', '');
|
||||
name = name.replace('VanEck ETFs N.V. - ', '');
|
||||
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
|
||||
name = name.replace('Vanguard Funds Public Limited Company - ', '');
|
||||
name = name.replace('Vanguard Index Funds - ', '');
|
||||
name = name.replace('Xtrackers (IE) Plc - ', '');
|
||||
}
|
||||
|
||||
return name || aAssetProfile.price.shortName || aAssetProfile.price.symbol;
|
||||
}
|
||||
|
||||
private parseAssetClass(aPrice: Price): {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
|
@ -4,7 +4,12 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import {
|
||||
DataSource,
|
||||
Prisma,
|
||||
SymbolProfile,
|
||||
SymbolProfileOverrides
|
||||
} from '@prisma/client';
|
||||
import { continents, countries } from 'countries-list';
|
||||
|
||||
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
|
||||
@ -36,6 +41,7 @@ export class SymbolProfileService {
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prismaService.symbolProfile
|
||||
.findMany({
|
||||
include: { SymbolProfileOverrides: true },
|
||||
where: {
|
||||
symbol: {
|
||||
in: symbols
|
||||
@ -45,14 +51,38 @@ export class SymbolProfileService {
|
||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||
}
|
||||
|
||||
private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] {
|
||||
return symbolProfiles.map((symbolProfile) => ({
|
||||
...symbolProfile,
|
||||
countries: this.getCountries(symbolProfile),
|
||||
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||
sectors: this.getSectors(symbolProfile),
|
||||
symbolMapping: this.getSymbolMapping(symbolProfile)
|
||||
}));
|
||||
private getSymbols(
|
||||
symbolProfiles: (SymbolProfile & {
|
||||
SymbolProfileOverrides: SymbolProfileOverrides;
|
||||
})[]
|
||||
): EnhancedSymbolProfile[] {
|
||||
return symbolProfiles.map((symbolProfile) => {
|
||||
const item = {
|
||||
...symbolProfile,
|
||||
countries: this.getCountries(symbolProfile),
|
||||
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||
sectors: this.getSectors(symbolProfile),
|
||||
symbolMapping: this.getSymbolMapping(symbolProfile)
|
||||
};
|
||||
|
||||
if (item.SymbolProfileOverrides) {
|
||||
item.assetClass =
|
||||
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
|
||||
item.assetSubClass =
|
||||
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
|
||||
item.countries =
|
||||
(item.SymbolProfileOverrides.sectors as unknown as Country[]) ??
|
||||
item.countries;
|
||||
item.name = item.SymbolProfileOverrides?.name ?? item.name;
|
||||
item.sectors =
|
||||
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
|
||||
item.sectors;
|
||||
|
||||
delete item.SymbolProfileOverrides;
|
||||
}
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
private getCountries(symbolProfile: SymbolProfile): Country[] {
|
||||
|
@ -194,16 +194,17 @@
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<button i18n mat-menu-item (click)="onUpdateAccount(element)">
|
||||
Edit
|
||||
<button mat-menu-item (click)="onUpdateAccount(element)">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<span i18n>Edit</span>
|
||||
</button>
|
||||
<button
|
||||
i18n
|
||||
mat-menu-item
|
||||
[disabled]="element.isDefault || element.Order?.length > 0"
|
||||
(click)="onDeleteAccount(element.id)"
|
||||
>
|
||||
Delete
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<span i18n>Delete</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
|
@ -68,12 +68,12 @@
|
||||
</button>
|
||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
i18n
|
||||
mat-menu-item
|
||||
[disabled]="userItem.id === user?.id"
|
||||
(click)="onDeleteUser(userItem.id)"
|
||||
>
|
||||
Delete
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<span i18n>Delete</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
|
@ -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'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -109,38 +109,39 @@
|
||||
<mat-card-content>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0">{{ statistics?.activeUsers1d || '-' }}</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>Active Users</span> <small class="text-muted"
|
||||
>(Last 24 hours)</small
|
||||
>
|
||||
</div>
|
||||
<gf-value
|
||||
label="Active Users"
|
||||
size="large"
|
||||
subLabel="(Last 24 hours)"
|
||||
[value]="statistics?.activeUsers1d ?? '-'"
|
||||
></gf-value>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0">{{ statistics?.newUsers30d ?? '-' }}</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>New Users</span> <small class="text-muted"
|
||||
>(Last 30 days)</small
|
||||
>
|
||||
</div>
|
||||
<gf-value
|
||||
label="New Users"
|
||||
size="large"
|
||||
subLabel="(Last 30 days)"
|
||||
[value]="statistics?.newUsers30d ?? '-'"
|
||||
></gf-value>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0">{{ statistics?.activeUsers30d ?? '-' }}</h3>
|
||||
<div class="h6 mb-0">
|
||||
<span i18n>Active Users</span> <small class="text-muted"
|
||||
>(Last 30 days)</small
|
||||
>
|
||||
</div>
|
||||
<gf-value
|
||||
label="Active Users"
|
||||
size="large"
|
||||
subLabel="(Last 30 days)"
|
||||
[value]="statistics?.activeUsers30d ?? '-'"
|
||||
></gf-value>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<a
|
||||
class="d-block"
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
>
|
||||
<h3 class="mb-0">
|
||||
{{ statistics?.slackCommunityUsers ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0" i18n>Users in Slack community</div>
|
||||
<gf-value
|
||||
label="Users in Slack community"
|
||||
size="large"
|
||||
[value]="statistics?.slackCommunityUsers ?? '-'"
|
||||
></gf-value>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
@ -148,10 +149,11 @@
|
||||
class="d-block"
|
||||
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
|
||||
>
|
||||
<h3 class="mb-0">
|
||||
{{ statistics?.gitHubContributors ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
||||
<gf-value
|
||||
label="Contributors on GitHub"
|
||||
size="large"
|
||||
[value]="statistics?.gitHubContributors ?? '-'"
|
||||
></gf-value>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
@ -159,8 +161,11 @@
|
||||
class="d-block"
|
||||
href="https://github.com/ghostfolio/ghostfolio/stargazers"
|
||||
>
|
||||
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
|
||||
<div class="h6 mb-0" i18n>Stars on GitHub</div>
|
||||
<gf-value
|
||||
label="Stars on GitHub"
|
||||
size="large"
|
||||
[value]="statistics?.gitHubStargazers ?? '-'"
|
||||
></gf-value>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
import { AboutPageRoutingModule } from './about-page-routing.module';
|
||||
import { AboutPageComponent } from './about-page.component';
|
||||
@ -12,6 +13,7 @@ import { AboutPageComponent } from './about-page.component';
|
||||
imports: [
|
||||
AboutPageRoutingModule,
|
||||
CommonModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatCardModule
|
||||
],
|
||||
|
@ -68,6 +68,13 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onSavingsRateChange(savingsRate: number) {
|
||||
this.dataService
|
||||
.putUserSetting({ savingsRate })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
|
@ -41,7 +41,7 @@
|
||||
[value]="withdrawalRatePerMonth?.toNumber()"
|
||||
></gf-value>
|
||||
per month</span
|
||||
>, based on your investment of
|
||||
>, based on your total assets of
|
||||
<gf-value
|
||||
class="d-inline-block"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
@ -60,6 +60,8 @@
|
||||
[deviceType]="deviceType"
|
||||
[fireWealth]="fireWealth?.toNumber()"
|
||||
[locale]="user?.settings?.locale"
|
||||
[savingsRate]="user?.settings?.savingsRate"
|
||||
(savingsRateChanged)="onSavingsRateChange($event)"
|
||||
></gf-fire-calculator>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -46,6 +46,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
public filteredLookupItemsObservable: Observable<LookupItem[]>;
|
||||
public isLoading = false;
|
||||
public platforms: { id: string; name: string }[];
|
||||
public total = 0;
|
||||
public Validators = Validators;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -89,6 +90,25 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
unitPrice: [this.data.activity?.unitPrice, Validators.required]
|
||||
});
|
||||
|
||||
this.activityForm.valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
if (
|
||||
this.activityForm.controls['type'].value === 'BUY' ||
|
||||
this.activityForm.controls['type'].value === 'ITEM'
|
||||
) {
|
||||
this.total =
|
||||
this.activityForm.controls['quantity'].value *
|
||||
this.activityForm.controls['unitPrice'].value +
|
||||
this.activityForm.controls['fee'].value ?? 0;
|
||||
} else {
|
||||
this.total =
|
||||
this.activityForm.controls['quantity'].value *
|
||||
this.activityForm.controls['unitPrice'].value -
|
||||
this.activityForm.controls['fee'].value ?? 0;
|
||||
}
|
||||
});
|
||||
|
||||
this.filteredLookupItemsObservable = this.activityForm.controls[
|
||||
'searchSymbol'
|
||||
].valueChanges.pipe(
|
||||
@ -100,9 +120,11 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
const filteredLookupItemsObservable =
|
||||
this.dataService.fetchSymbols(query);
|
||||
|
||||
filteredLookupItemsObservable.subscribe((filteredLookupItems) => {
|
||||
this.filteredLookupItems = filteredLookupItems;
|
||||
});
|
||||
filteredLookupItemsObservable
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((filteredLookupItems) => {
|
||||
this.filteredLookupItems = filteredLookupItems;
|
||||
});
|
||||
|
||||
return filteredLookupItemsObservable;
|
||||
}
|
||||
@ -111,45 +133,47 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
})
|
||||
);
|
||||
|
||||
this.activityForm.controls['type'].valueChanges.subscribe((type: Type) => {
|
||||
if (type === 'ITEM') {
|
||||
this.activityForm.controls['accountId'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['accountId'].updateValueAndValidity();
|
||||
this.activityForm.controls['currency'].setValue(
|
||||
this.data.user.settings.baseCurrency
|
||||
);
|
||||
this.activityForm.controls['dataSource'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['dataSource'].updateValueAndValidity();
|
||||
this.activityForm.controls['name'].setValidators(Validators.required);
|
||||
this.activityForm.controls['name'].updateValueAndValidity();
|
||||
this.activityForm.controls['quantity'].setValue(1);
|
||||
this.activityForm.controls['searchSymbol'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
|
||||
} else {
|
||||
this.activityForm.controls['accountId'].setValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['accountId'].updateValueAndValidity();
|
||||
this.activityForm.controls['dataSource'].setValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['dataSource'].updateValueAndValidity();
|
||||
this.activityForm.controls['name'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['name'].updateValueAndValidity();
|
||||
this.activityForm.controls['searchSymbol'].setValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
|
||||
}
|
||||
});
|
||||
this.activityForm.controls['type'].valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((type: Type) => {
|
||||
if (type === 'ITEM') {
|
||||
this.activityForm.controls['accountId'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['accountId'].updateValueAndValidity();
|
||||
this.activityForm.controls['currency'].setValue(
|
||||
this.data.user.settings.baseCurrency
|
||||
);
|
||||
this.activityForm.controls['dataSource'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['dataSource'].updateValueAndValidity();
|
||||
this.activityForm.controls['name'].setValidators(Validators.required);
|
||||
this.activityForm.controls['name'].updateValueAndValidity();
|
||||
this.activityForm.controls['quantity'].setValue(1);
|
||||
this.activityForm.controls['searchSymbol'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
|
||||
} else {
|
||||
this.activityForm.controls['accountId'].setValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['accountId'].updateValueAndValidity();
|
||||
this.activityForm.controls['dataSource'].setValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['dataSource'].updateValueAndValidity();
|
||||
this.activityForm.controls['name'].removeValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['name'].updateValueAndValidity();
|
||||
this.activityForm.controls['searchSymbol'].setValidators(
|
||||
Validators.required
|
||||
);
|
||||
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
|
||||
}
|
||||
});
|
||||
|
||||
this.activityForm.controls['type'].setValue(this.data.activity?.type);
|
||||
|
||||
|
@ -138,9 +138,9 @@
|
||||
<div class="d-flex" mat-dialog-actions>
|
||||
<gf-value
|
||||
class="flex-grow-1"
|
||||
[currency]="activityForm.controls['currency'].value"
|
||||
[currency]="activityForm.controls['currency']?.value ?? data.user?.settings?.baseCurrency"
|
||||
[locale]="data.user?.settings?.locale"
|
||||
[value]="activityForm.controls['fee'].value + (activityForm.controls['quantity'].value * activityForm.controls['unitPrice'].value) ?? 0"
|
||||
[value]="total"
|
||||
></gf-value>
|
||||
<div>
|
||||
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
|
||||
|
@ -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
|
||||
),
|
||||
contentType: 'text/calendar',
|
||||
fileName: `ghostfolio-draft${
|
||||
data.activities.length > 1 ? 's' : ''
|
||||
}-${format(parseISO(data.meta.date), 'yyyyMMddHHmmss')}.ics`,
|
||||
format: 'string'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -15,6 +15,7 @@
|
||||
(activityToClone)="onCloneTransaction($event)"
|
||||
(activityToUpdate)="onUpdateTransaction($event)"
|
||||
(export)="onExport($event)"
|
||||
(exportDrafts)="onExportDrafts($event)"
|
||||
(import)="onImport()"
|
||||
></gf-activities-table>
|
||||
</div>
|
||||
|
@ -1,105 +1,117 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Resources</h3>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-content>
|
||||
<h4 class="mb-3">Market</h4>
|
||||
<div class="mb-5">
|
||||
<div class="mb-4 media">
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0">Fear & Greed Index</h5>
|
||||
<div class="mb-1">
|
||||
The fear and greed index was developed by <i>CNNMoney</i> to
|
||||
measure the primary emotions (fear and greed) that influence
|
||||
how much investors are willing to pay for stocks.
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://money.cnn.com/data/fear-and-greed/"
|
||||
target="_blank"
|
||||
>Fear & Greed Index →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<h1 class="d-flex h3 justify-content-center mb-3" i18n>Resources</h1>
|
||||
<h2 class="h4 mb-3">Guides</h2>
|
||||
<div class="mb-5">
|
||||
<div class="mb-4 media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Boringly Getting Rich</h3>
|
||||
<div class="mb-1">
|
||||
The <i>Boringly Getting Rich</i> guide supports you to get started
|
||||
with investing. It introduces a strategy utilizing a broadly
|
||||
diversified, low-cost portfolio excluding the risks of individual
|
||||
stocks.
|
||||
</div>
|
||||
<div class="media">
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0">Inflation Chart</h5>
|
||||
<div class="mb-1">
|
||||
Inflation Chart helps you find the intrinsic value of stock
|
||||
markets, stock prices, goods and services by adjusting them to
|
||||
the amount of the money supply (M0, M1, M2) or price of other
|
||||
goods (food or oil).
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://inflationchart.com" target="_blank"
|
||||
>Inflation Chart →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://herget.me/investing-guide" target="_blank"
|
||||
>Boringly Getting Rich →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="mb-3">Glossary</h4>
|
||||
<div>
|
||||
<div class="mb-4 media">
|
||||
<!--<img src="" class="mr-3" />-->
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0">Buy and Hold</h5>
|
||||
<div class="mb-1">
|
||||
Buy and hold is a passive investment strategy where you buy
|
||||
assets and hold them for a long period regardless of
|
||||
fluctuations in the market.
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.investopedia.com/terms/b/buyandhold.asp"
|
||||
target="_blank"
|
||||
>Buy and Hold →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="h4 mb-3">Market</h2>
|
||||
<div class="mb-5">
|
||||
<div class="mb-4 media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Fear & Greed Index</h3>
|
||||
<div class="mb-1">
|
||||
The fear and greed index was developed by <i>CNNMoney</i> to
|
||||
measure the primary emotions (fear and greed) that influence how
|
||||
much investors are willing to pay for stocks.
|
||||
</div>
|
||||
<div class="mb-4 media">
|
||||
<!--<img src="" class="mr-3" />-->
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0">Dollar-Cost Averaging (DCA)</h5>
|
||||
<div class="mb-1">
|
||||
Dollar-cost averaging is an investment strategy where you
|
||||
split the total amount to be invested across periodic
|
||||
purchases of a target asset to reduce the impact of volatility
|
||||
on the overall purchase.
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.investopedia.com/terms/d/dollarcostaveraging.asp"
|
||||
target="_blank"
|
||||
>Dollar-Cost Averaging →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media">
|
||||
<!--<img src="" class="mr-3" />-->
|
||||
<div class="media-body">
|
||||
<h5 class="mt-0">Financial Independence</h5>
|
||||
<div class="mb-1">
|
||||
Financial independence is the status of having enough income,
|
||||
for example with a passive income like dividends, to cover
|
||||
your living expenses for the rest of your life.
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Financial_independence"
|
||||
target="_blank"
|
||||
>Financial Independence →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://money.cnn.com/data/fear-and-greed/"
|
||||
target="_blank"
|
||||
>Fear & Greed Index →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Inflation Chart</h3>
|
||||
<div class="mb-1">
|
||||
Inflation Chart helps you find the intrinsic value of stock
|
||||
markets, stock prices, goods and services by adjusting them to the
|
||||
amount of the money supply (M0, M1, M2) or price of other goods
|
||||
(food or oil).
|
||||
</div>
|
||||
<div>
|
||||
<a href="https://inflationchart.com" target="_blank"
|
||||
>Inflation Chart →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="h4 mb-3">Glossary</h2>
|
||||
<div>
|
||||
<div class="mb-4 media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Buy and Hold</h3>
|
||||
<div class="mb-1">
|
||||
Buy and hold is a passive investment strategy where you buy assets
|
||||
and hold them for a long period regardless of fluctuations in the
|
||||
market.
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.investopedia.com/terms/b/buyandhold.asp"
|
||||
target="_blank"
|
||||
>Buy and Hold →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Dollar-Cost Averaging (DCA)</h3>
|
||||
<div class="mb-1">
|
||||
Dollar-cost averaging is an investment strategy where you split
|
||||
the total amount to be invested across periodic purchases of a
|
||||
target asset to reduce the impact of volatility on the overall
|
||||
purchase.
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.investopedia.com/terms/d/dollarcostaveraging.asp"
|
||||
target="_blank"
|
||||
>Dollar-Cost Averaging →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Financial Independence</h3>
|
||||
<div class="mb-1">
|
||||
Financial independence is the status of having enough income, for
|
||||
example with a passive income like dividends, to cover your living
|
||||
expenses for the rest of your life.
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Financial_independence"
|
||||
target="_blank"
|
||||
>Financial Independence →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,14 +2,12 @@
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
|
||||
.mat-card {
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: 500;
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
color: rgba(var(--palette-primary-300), 1);
|
||||
}
|
||||
&:hover {
|
||||
color: rgba(var(--palette-primary-300), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
59
apps/client/src/app/services/ics/ics.service.ts
Normal file
59
apps/client/src/app/services/ics/ics.service.ts
Normal 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';
|
||||
private readonly ICS_LINE_BREAK = '\r\n';
|
||||
|
||||
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(this.ICS_LINE_BREAK);
|
||||
}
|
||||
|
||||
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)}`,
|
||||
`SUMMARY:${capitalize(type)} ${symbol}`,
|
||||
'END:VEVENT'
|
||||
].join(this.ICS_LINE_BREAK);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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 })[];
|
||||
}
|
||||
|
@ -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>
|
||||
@ -374,14 +387,25 @@
|
||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||
</button>
|
||||
<mat-menu #activityMenu="matMenu" xPosition="before">
|
||||
<button i18n mat-menu-item (click)="onUpdateActivity(element)">
|
||||
Edit
|
||||
<button mat-menu-item (click)="onUpdateActivity(element)">
|
||||
<ion-icon class="mr-2" name="create-outline"></ion-icon>
|
||||
<span i18n>Edit</span>
|
||||
</button>
|
||||
<button i18n mat-menu-item (click)="onCloneActivity(element)">
|
||||
Clone
|
||||
<button mat-menu-item (click)="onCloneActivity(element)">
|
||||
<ion-icon class="mr-2" name="copy-outline"></ion-icon>
|
||||
<span i18n>Clone</span>
|
||||
</button>
|
||||
<button i18n mat-menu-item (click)="onDeleteActivity(element.id)">
|
||||
Delete
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="!element.isDraft"
|
||||
(click)="onExportDraft(element.id)"
|
||||
>
|
||||
<ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon>
|
||||
<span i18n>Export Draft as ICS</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onDeleteActivity(element.id)">
|
||||
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
|
||||
<span i18n>Delete</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
|
@ -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,22 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
public onExportDraft(aActivityId: string) {
|
||||
this.exportDrafts.emit([aActivityId]);
|
||||
}
|
||||
|
||||
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 +252,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 +329,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;
|
||||
|
@ -5,13 +5,15 @@ import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormControl } from '@angular/forms';
|
||||
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
|
||||
import { primaryColorRgb } from '@ghostfolio/common/config';
|
||||
import { transformTickToAbbreviation } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
BarController,
|
||||
@ -21,6 +23,7 @@ import {
|
||||
LinearScale,
|
||||
Tooltip
|
||||
} from 'chart.js';
|
||||
import * as Color from 'color';
|
||||
import { isNumber } from 'lodash';
|
||||
import { Subject, takeUntil } from 'rxjs';
|
||||
|
||||
@ -39,6 +42,9 @@ export class FireCalculatorComponent
|
||||
@Input() deviceType: string;
|
||||
@Input() fireWealth: number;
|
||||
@Input() locale: string;
|
||||
@Input() savingsRate = 0;
|
||||
|
||||
@Output() savingsRateChanged = new EventEmitter<number>();
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas;
|
||||
|
||||
@ -72,7 +78,7 @@ export class FireCalculatorComponent
|
||||
|
||||
this.calculatorForm.setValue({
|
||||
annualInterestRate: 5,
|
||||
paymentPerPeriod: 500,
|
||||
paymentPerPeriod: this.savingsRate,
|
||||
principalInvestmentAmount: 0,
|
||||
time: 10
|
||||
});
|
||||
@ -82,15 +88,28 @@ export class FireCalculatorComponent
|
||||
.subscribe(() => {
|
||||
this.initialize();
|
||||
});
|
||||
|
||||
this.calculatorForm
|
||||
.get('paymentPerPeriod')
|
||||
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((savingsRate) => {
|
||||
this.savingsRateChanged.emit(savingsRate);
|
||||
});
|
||||
}
|
||||
|
||||
public ngAfterViewInit() {
|
||||
if (isNumber(this.fireWealth) && this.fireWealth >= 0) {
|
||||
setTimeout(() => {
|
||||
// Wait for the chartCanvas
|
||||
this.calculatorForm.patchValue({
|
||||
principalInvestmentAmount: this.fireWealth
|
||||
});
|
||||
this.calculatorForm.patchValue(
|
||||
{
|
||||
principalInvestmentAmount: this.fireWealth,
|
||||
paymentPerPeriod: this.savingsRate ?? 0
|
||||
},
|
||||
{
|
||||
emitEvent: false
|
||||
}
|
||||
);
|
||||
this.calculatorForm.get('principalInvestmentAmount').disable();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
@ -102,9 +121,15 @@ export class FireCalculatorComponent
|
||||
if (isNumber(this.fireWealth) && this.fireWealth >= 0) {
|
||||
setTimeout(() => {
|
||||
// Wait for the chartCanvas
|
||||
this.calculatorForm.patchValue({
|
||||
principalInvestmentAmount: this.fireWealth
|
||||
});
|
||||
this.calculatorForm.patchValue(
|
||||
{
|
||||
principalInvestmentAmount: this.fireWealth,
|
||||
paymentPerPeriod: this.savingsRate ?? 0
|
||||
},
|
||||
{
|
||||
emitEvent: false
|
||||
}
|
||||
);
|
||||
this.calculatorForm.get('principalInvestmentAmount').disable();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
@ -127,8 +152,10 @@ export class FireCalculatorComponent
|
||||
if (this.chartCanvas) {
|
||||
if (this.chart) {
|
||||
this.chart.data.labels = chartData.labels;
|
||||
this.chart.data.datasets[0].data = chartData.datasets[0].data;
|
||||
this.chart.data.datasets[1].data = chartData.datasets[1].data;
|
||||
|
||||
for (let index = 0; index < this.chart.data.datasets.length; index++) {
|
||||
this.chart.data.datasets[index].data = chartData.datasets[index].data;
|
||||
}
|
||||
|
||||
this.chart.update();
|
||||
} else {
|
||||
@ -137,7 +164,24 @@ export class FireCalculatorComponent
|
||||
options: {
|
||||
plugins: {
|
||||
tooltip: {
|
||||
itemSort: (a, b) => {
|
||||
// Reverse order
|
||||
return b.datasetIndex - a.datasetIndex;
|
||||
},
|
||||
mode: 'index',
|
||||
callbacks: {
|
||||
footer: (items) => {
|
||||
const totalAmount = items.reduce(
|
||||
(a, b) => a + b.parsed.y,
|
||||
0
|
||||
);
|
||||
|
||||
return `Total: ${new Intl.NumberFormat(this.locale, {
|
||||
currency: this.currency,
|
||||
currencyDisplay: 'code',
|
||||
style: 'currency'
|
||||
}).format(totalAmount)}`;
|
||||
},
|
||||
label: (context) => {
|
||||
let label = context.dataset.label || '';
|
||||
|
||||
@ -211,16 +255,30 @@ export class FireCalculatorComponent
|
||||
labels.push(year);
|
||||
}
|
||||
|
||||
const datasetDeposit = {
|
||||
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||
data: [],
|
||||
label: 'Deposit'
|
||||
};
|
||||
|
||||
const datasetInterest = {
|
||||
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
|
||||
backgroundColor: Color(
|
||||
`rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`
|
||||
)
|
||||
.lighten(0.5)
|
||||
.hex(),
|
||||
data: [],
|
||||
label: 'Interest'
|
||||
};
|
||||
|
||||
const datasetPrincipal = {
|
||||
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||
const datasetSavings = {
|
||||
backgroundColor: Color(
|
||||
`rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`
|
||||
)
|
||||
.lighten(0.25)
|
||||
.hex(),
|
||||
data: [],
|
||||
label: 'Principal'
|
||||
label: 'Savings'
|
||||
};
|
||||
|
||||
for (let period = 1; period <= t; period++) {
|
||||
@ -232,8 +290,9 @@ export class FireCalculatorComponent
|
||||
r
|
||||
});
|
||||
|
||||
datasetPrincipal.data.push(principal.toNumber());
|
||||
datasetDeposit.data.push(this.fireWealth);
|
||||
datasetInterest.data.push(interest.toNumber());
|
||||
datasetSavings.data.push(principal.minus(this.fireWealth).toNumber());
|
||||
|
||||
if (period === t) {
|
||||
this.projectedTotalAmount = totalAmount.toNumber();
|
||||
@ -242,7 +301,7 @@ export class FireCalculatorComponent
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [datasetPrincipal, datasetInterest]
|
||||
datasets: [datasetDeposit, datasetSavings, datasetInterest]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -10,14 +10,14 @@
|
||||
</ng-container>
|
||||
<div
|
||||
*ngIf="isPercent"
|
||||
class="mb-0"
|
||||
class="mb-0 value"
|
||||
[ngClass]="{ h2: size === 'large', h4: size === 'medium' }"
|
||||
>
|
||||
{{ formattedValue }}%
|
||||
</div>
|
||||
<div
|
||||
*ngIf="!isPercent"
|
||||
class="mb-0"
|
||||
class="mb-0 value"
|
||||
[ngClass]="{ h2: size === 'large', h4: size === 'medium' }"
|
||||
>
|
||||
<ng-container *ngIf="value === null">
|
||||
@ -36,7 +36,7 @@
|
||||
</ng-container>
|
||||
<ng-container *ngIf="isString">
|
||||
<div
|
||||
class="mb-0 text-truncate"
|
||||
class="mb-0 text-truncate value"
|
||||
[ngClass]="{ h2: size === 'large', h4: size === 'medium' }"
|
||||
>
|
||||
{{ formattedValue | titlecase }}
|
||||
@ -45,7 +45,8 @@
|
||||
</div>
|
||||
<ng-container *ngIf="label">
|
||||
<div *ngIf="size === 'large'">
|
||||
{{ label }}
|
||||
<span class="h6">{{ label }}</span>
|
||||
<span *ngIf="subLabel" class="text-muted"> {{ subLabel }}</span>
|
||||
</div>
|
||||
<small *ngIf="size !== 'large'">
|
||||
{{ label }}
|
||||
|
@ -2,4 +2,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
.h2 {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
@ -25,6 +25,7 @@ export class ValueComponent implements OnChanges {
|
||||
@Input() position = '';
|
||||
@Input() precision: number | undefined;
|
||||
@Input() size: 'large' | 'medium' | 'small' = 'small';
|
||||
@Input() subLabel = '';
|
||||
@Input() value: number | string = '';
|
||||
|
||||
public absoluteValue = 0;
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.136.0",
|
||||
"version": "1.140.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -118,7 +118,7 @@
|
||||
"tslib": "2.0.0",
|
||||
"twitter-api-v2": "1.10.3",
|
||||
"uuid": "8.3.2",
|
||||
"yahoo-finance2": "2.3.0",
|
||||
"yahoo-finance2": "2.3.1",
|
||||
"zone.js": "0.11.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
@ -0,0 +1,15 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "SymbolProfileOverrides" (
|
||||
"assetClass" "AssetClass",
|
||||
"assetSubClass" "AssetSubClass",
|
||||
"countries" JSONB,
|
||||
"name" TEXT,
|
||||
"sectors" JSONB,
|
||||
"symbolProfileId" TEXT NOT NULL,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SymbolProfileOverrides_pkey" PRIMARY KEY ("symbolProfileId")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SymbolProfileOverrides" ADD CONSTRAINT "SymbolProfileOverrides_symbolProfileId_fkey" FOREIGN KEY ("symbolProfileId") REFERENCES "SymbolProfile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "AssetSubClass" ADD VALUE 'COMMODITY';
|
@ -112,25 +112,37 @@ model Settings {
|
||||
}
|
||||
|
||||
model SymbolProfile {
|
||||
assetClass AssetClass?
|
||||
assetSubClass AssetSubClass?
|
||||
countries Json?
|
||||
createdAt DateTime @default(now())
|
||||
currency String
|
||||
dataSource DataSource
|
||||
id String @id @default(uuid())
|
||||
name String?
|
||||
Order Order[]
|
||||
updatedAt DateTime @updatedAt
|
||||
scraperConfiguration Json?
|
||||
sectors Json?
|
||||
symbol String
|
||||
symbolMapping Json?
|
||||
url String?
|
||||
assetClass AssetClass?
|
||||
assetSubClass AssetSubClass?
|
||||
countries Json?
|
||||
createdAt DateTime @default(now())
|
||||
currency String
|
||||
dataSource DataSource
|
||||
id String @id @default(uuid())
|
||||
name String?
|
||||
Order Order[]
|
||||
updatedAt DateTime @updatedAt
|
||||
scraperConfiguration Json?
|
||||
sectors Json?
|
||||
symbol String
|
||||
symbolMapping Json?
|
||||
SymbolProfileOverrides SymbolProfileOverrides?
|
||||
url String?
|
||||
|
||||
@@unique([dataSource, symbol])
|
||||
}
|
||||
|
||||
model SymbolProfileOverrides {
|
||||
assetClass AssetClass?
|
||||
assetSubClass AssetSubClass?
|
||||
countries Json?
|
||||
name String?
|
||||
sectors Json?
|
||||
SymbolProfile SymbolProfile @relation(fields: [symbolProfileId], references: [id])
|
||||
symbolProfileId String @id
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Subscription {
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
@ -176,6 +188,7 @@ enum AssetClass {
|
||||
|
||||
enum AssetSubClass {
|
||||
BOND
|
||||
COMMODITY
|
||||
CRYPTOCURRENCY
|
||||
ETF
|
||||
MUTUALFUND
|
||||
|
@ -18836,10 +18836,10 @@ y18n@^5.0.5:
|
||||
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
|
||||
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
|
||||
|
||||
yahoo-finance2@2.3.0:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.3.0.tgz#81bd76732dfd38aa5d7019a97caf0f938c0127c2"
|
||||
integrity sha512-7oj8n/WJH9MtX+q99WbHdjEVPdobTX8IyYjg7v4sDOh4f9ByT2Frxmp+Uj+rctrO0EiiD9QWTuwV4h8AemGuCg==
|
||||
yahoo-finance2@2.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.3.1.tgz#d2cffbef78f6974e4e6a40487cc08ab133dc9fc5"
|
||||
integrity sha512-QTXiiWgfrpVbSylchBgLqESZz+8+SyyDSqntjfZHxMIHa6d14xq+biNNDIeYd5SylcZ9Vt4zLmZXHN7EdLM1pA==
|
||||
dependencies:
|
||||
ajv "8.10.0"
|
||||
ajv-formats "2.1.1"
|
||||
|
Reference in New Issue
Block a user