Compare commits
23 Commits
Author | SHA1 | Date | |
---|---|---|---|
de2255f9ba | |||
e4ec5f213e | |||
f3c2fb853d | |||
f5ad1d2d24 | |||
0af37ca1d7 | |||
2992a0da4c | |||
2dcc7e161c | |||
fa627f686f | |||
0567083fc1 | |||
3212efef17 | |||
6077e7c2f9 | |||
96b5dcfaf8 | |||
c4e8e37884 | |||
281d33f825 | |||
5822e4d186 | |||
cb166dcc78 | |||
4e7b7375a9 | |||
b8626c2086 | |||
a59f9fa037 | |||
1666486940 | |||
ac0ad48a65 | |||
6a19eab425 | |||
750c627613 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -27,6 +27,7 @@
|
||||
/.angular/cache
|
||||
.env
|
||||
.env.prod
|
||||
.nx/cache
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
|
@ -1,2 +1,3 @@
|
||||
/.nx/cache
|
||||
/dist
|
||||
/test/import
|
||||
|
42
CHANGELOG.md
42
CHANGELOG.md
@ -5,6 +5,48 @@ 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).
|
||||
|
||||
## 2.15.0 - 2023-10-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to edit the name, asset class and asset sub class of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the style and wording of the position detail dialog
|
||||
- Improved the validation in the activities import (expects positive values for `fee`, `quantity` and `unitPrice`)
|
||||
- Improved the validation in the cash balance transfer from one to another account (expects a positive value)
|
||||
- Changed the currency selector in the create or update account dialog to `@angular/material/autocomplete`
|
||||
- Upgraded `Nx` from version `16.7.4` to `17.0.2`
|
||||
- Upgraded `uuid` from version `9.0.0` to `9.0.1`
|
||||
- Upgraded `yahoo-finance2` from version `2.8.0` to `2.8.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the chart in the account detail dialog for accounts excluded from analysis
|
||||
- Verified the current benchmark before loading it on the analysis page
|
||||
|
||||
## 2.14.0 - 2023-10-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added the _OpenFIGI_ data enhancer for _Financial Instrument Global Identifier_ (FIGI)
|
||||
- Added `figi`, `figiComposite` and `figiShareClass` to the asset profile model
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the fees on account level feature from experimental to general availability
|
||||
- Moved the interest on account level feature from experimental to general availability
|
||||
- Moved the search for a holding from experimental to general availability
|
||||
- Improved the error message in the activities import for `csv` files
|
||||
- Removed the application version from the client
|
||||
- Allowed to edit today’s historical market data in the asset profile details dialog of the admin control panel
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the style of the active page in the header navigation
|
||||
- Trimmed text in `i18n` service to query `messages.*.xlf` files on the server
|
||||
|
||||
## 2.13.0 - 2023-10-20
|
||||
|
||||
### Added
|
||||
|
@ -1,5 +1,17 @@
|
||||
# Ghostfolio Development Guide
|
||||
|
||||
## Experimental Features
|
||||
|
||||
New functionality can be enabled using a feature flag switch from the user settings.
|
||||
|
||||
### Backend
|
||||
|
||||
Remove permission in `UserService` using `without()`
|
||||
|
||||
### Frontend
|
||||
|
||||
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||
|
||||
## Git
|
||||
|
||||
### Rebase
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { IsNumber, IsString } from 'class-validator';
|
||||
import { IsNumber, IsPositive, IsString } from 'class-validator';
|
||||
|
||||
export class TransferBalanceDto {
|
||||
@IsString()
|
||||
@ -8,5 +8,6 @@ export class TransferBalanceDto {
|
||||
accountIdTo: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
balance: number;
|
||||
}
|
||||
|
@ -303,15 +303,21 @@ export class AdminService {
|
||||
}
|
||||
|
||||
public async patchAssetProfileData({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
await this.symbolProfileService.updateSymbolProfile({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
|
@ -1,11 +1,23 @@
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||
import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateAssetProfileDto {
|
||||
@IsEnum(AssetClass, { each: true })
|
||||
@IsOptional()
|
||||
assetClass?: AssetClass;
|
||||
|
||||
@IsEnum(AssetSubClass, { each: true })
|
||||
@IsOptional()
|
||||
assetSubClass?: AssetSubClass;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
scraperConfiguration?: Prisma.InputJsonObject;
|
||||
|
@ -280,6 +280,9 @@ export class ImportService {
|
||||
createdAt,
|
||||
currency,
|
||||
dataSource,
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
id,
|
||||
isin,
|
||||
name,
|
||||
@ -350,6 +353,9 @@ export class ImportService {
|
||||
createdAt,
|
||||
currency,
|
||||
dataSource,
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
id,
|
||||
isin,
|
||||
name,
|
||||
@ -509,6 +515,9 @@ export class ImportService {
|
||||
comment: null,
|
||||
countries: null,
|
||||
createdAt: undefined,
|
||||
figi: null,
|
||||
figiComposite: null,
|
||||
figiShareClass: null,
|
||||
id: undefined,
|
||||
isin: null,
|
||||
name: null,
|
||||
|
@ -13,7 +13,9 @@ import {
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString
|
||||
IsPositive,
|
||||
IsString,
|
||||
Min
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
@ -48,9 +50,11 @@ export class CreateOrderDto {
|
||||
date: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
fee: number;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
quantity: number;
|
||||
|
||||
@IsString()
|
||||
@ -64,6 +68,7 @@ export class CreateOrderDto {
|
||||
type: Type;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
unitPrice: number;
|
||||
|
||||
@IsBoolean()
|
||||
|
@ -13,7 +13,9 @@ import {
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString
|
||||
IsPositive,
|
||||
IsString,
|
||||
Min
|
||||
} from 'class-validator';
|
||||
import { isString } from 'lodash';
|
||||
|
||||
@ -47,12 +49,14 @@ export class UpdateOrderDto {
|
||||
date: string;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
fee: number;
|
||||
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
quantity: number;
|
||||
|
||||
@IsString()
|
||||
@ -66,5 +70,6 @@ export class UpdateOrderDto {
|
||||
type: Type;
|
||||
|
||||
@IsNumber()
|
||||
@IsPositive()
|
||||
unitPrice: number;
|
||||
}
|
||||
|
@ -323,7 +323,8 @@ export class PortfolioController {
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withExcludedAccounts') withExcludedAccounts = false
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
@ -335,6 +336,7 @@ export class PortfolioController {
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
withExcludedAccounts,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
|
@ -372,20 +372,23 @@ export class PortfolioService {
|
||||
filters,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<HistoricalDataContainer> {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
@ -1110,12 +1113,14 @@ export class PortfolioService {
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
impersonationId,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<PortfolioPerformanceResponse> {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
@ -1124,7 +1129,8 @@ export class PortfolioService {
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
@ -1174,7 +1180,8 @@ export class PortfolioService {
|
||||
filters,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
const itemOfToday = historicalDataContainer.items.find((item) => {
|
||||
@ -1763,7 +1770,7 @@ export class PortfolioService {
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
@ -1851,7 +1858,7 @@ export class PortfolioService {
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
orders: OrderWithAccount[];
|
||||
|
@ -104,7 +104,7 @@ export class SubscriptionController {
|
||||
response.redirect(
|
||||
`${this.configurationService.get(
|
||||
'ROOT_URL'
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/account`
|
||||
)}/${DEFAULT_LANGUAGE_CODE}/account/membership`
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -164,10 +164,10 @@ export class UserService {
|
||||
let currentPermissions = getPermissions(user.role);
|
||||
|
||||
if (!(user.Settings.settings as UserSettings).isExperimentalFeatures) {
|
||||
currentPermissions = without(
|
||||
currentPermissions,
|
||||
permissions.accessAssistant
|
||||
);
|
||||
// currentPermissions = without(
|
||||
// currentPermissions,
|
||||
// permissions.xyz
|
||||
// );
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
|
@ -58,6 +58,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-beanvest</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-capmon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -166,6 +170,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -312,6 +320,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-beanvest</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-capmon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -420,6 +432,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -594,6 +610,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-altoo</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-beanvest</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-campmon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -702,6 +722,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-utluna</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -722,6 +746,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-altoo</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-beanvest</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-capmon</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -830,6 +858,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-utluna</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthica</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-yeekatee</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
|
@ -38,6 +38,7 @@ export class ConfigurationService {
|
||||
JWT_SECRET_KEY: str({}),
|
||||
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
OPEN_FIGI_API_KEY: str({ default: '' }),
|
||||
PORT: port({ default: 3333 }),
|
||||
RAPID_API_API_KEY: str({ default: '' }),
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
|
@ -164,6 +164,9 @@ export class DataGatheringService {
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
isin,
|
||||
name,
|
||||
sectors,
|
||||
@ -178,6 +181,9 @@ export class DataGatheringService {
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
isin,
|
||||
name,
|
||||
sectors,
|
||||
@ -189,6 +195,9 @@ export class DataGatheringService {
|
||||
assetSubClass,
|
||||
countries,
|
||||
currency,
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
isin,
|
||||
name,
|
||||
sectors,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service';
|
||||
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service';
|
||||
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -9,6 +10,7 @@ import { DataEnhancerService } from './data-enhancer.service';
|
||||
@Module({
|
||||
exports: [
|
||||
DataEnhancerService,
|
||||
OpenFigiDataEnhancerService,
|
||||
TrackinsightDataEnhancerService,
|
||||
YahooFinanceDataEnhancerService,
|
||||
'DataEnhancers'
|
||||
@ -16,15 +18,21 @@ import { DataEnhancerService } from './data-enhancer.service';
|
||||
imports: [ConfigurationModule, CryptocurrencyModule],
|
||||
providers: [
|
||||
DataEnhancerService,
|
||||
OpenFigiDataEnhancerService,
|
||||
TrackinsightDataEnhancerService,
|
||||
YahooFinanceDataEnhancerService,
|
||||
{
|
||||
inject: [
|
||||
OpenFigiDataEnhancerService,
|
||||
TrackinsightDataEnhancerService,
|
||||
YahooFinanceDataEnhancerService
|
||||
],
|
||||
provide: 'DataEnhancers',
|
||||
useFactory: (trackinsight, yahooFinance) => [trackinsight, yahooFinance]
|
||||
useFactory: (openfigi, trackinsight, yahooFinance) => [
|
||||
openfigi,
|
||||
trackinsight,
|
||||
yahooFinance
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
@ -0,0 +1,85 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||
import { parseSymbol } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import got, { Headers } from 'got';
|
||||
|
||||
@Injectable()
|
||||
export class OpenFigiDataEnhancerService implements DataEnhancerInterface {
|
||||
private static baseUrl = 'https://api.openfigi.com';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
|
||||
public async enhance({
|
||||
response,
|
||||
symbol
|
||||
}: {
|
||||
response: Partial<SymbolProfile>;
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
if (
|
||||
!(
|
||||
response.assetClass === 'EQUITY' &&
|
||||
(response.assetSubClass === 'ETF' || response.assetSubClass === 'STOCK')
|
||||
)
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const headers: Headers = {};
|
||||
const { exchange, ticker } = parseSymbol({
|
||||
symbol,
|
||||
dataSource: response.dataSource
|
||||
});
|
||||
|
||||
if (this.configurationService.get('OPEN_FIGI_API_KEY')) {
|
||||
headers['X-OPENFIGI-APIKEY'] =
|
||||
this.configurationService.get('OPEN_FIGI_API_KEY');
|
||||
}
|
||||
|
||||
let abortController = new AbortController();
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
|
||||
const mappings = await got
|
||||
.post(`${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, {
|
||||
headers,
|
||||
json: [{ exchCode: exchange, idType: 'TICKER', idValue: ticker }],
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
})
|
||||
.json<any[]>();
|
||||
|
||||
if (mappings?.length === 1 && mappings[0].data?.length === 1) {
|
||||
const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0];
|
||||
|
||||
if (figi) {
|
||||
response.figi = figi;
|
||||
}
|
||||
|
||||
if (compositeFIGI) {
|
||||
response.figiComposite = compositeFIGI;
|
||||
}
|
||||
|
||||
if (shareClassFIGI) {
|
||||
response.figiShareClass = shareClassFIGI;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public getName() {
|
||||
return 'OPENFIGI';
|
||||
}
|
||||
|
||||
public getTestSymbol() {
|
||||
return undefined;
|
||||
}
|
||||
}
|
@ -38,7 +38,7 @@ export class I18nService {
|
||||
);
|
||||
}
|
||||
|
||||
return translatedText;
|
||||
return translatedText.trim();
|
||||
}
|
||||
|
||||
private loadFiles() {
|
||||
|
@ -26,6 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
JWT_SECRET_KEY: string;
|
||||
MAX_ACTIVITIES_TO_IMPORT: number;
|
||||
MAX_ITEM_IN_CACHE: number;
|
||||
OPEN_FIGI_API_KEY: string;
|
||||
PORT: number;
|
||||
RAPID_API_API_KEY: string;
|
||||
REDIS_HOST: string;
|
||||
|
@ -86,14 +86,24 @@ export class SymbolProfileService {
|
||||
}
|
||||
|
||||
public updateSymbolProfile({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
return this.prismaService.symbolProfile.update({
|
||||
data: { comment, scraperConfiguration, symbolMapping },
|
||||
data: {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbolMapping
|
||||
},
|
||||
where: { dataSource_symbol: { dataSource, symbol } }
|
||||
});
|
||||
}
|
||||
|
@ -165,7 +165,6 @@
|
||||
<div class="row text-center">
|
||||
<div class="col">
|
||||
© 2021 - {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>
|
||||
{{ version }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -17,7 +17,6 @@ import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { filter, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { environment } from '../environments/environment';
|
||||
import { DataService } from './services/data.service';
|
||||
import { TokenStorageService } from './services/token-storage.service';
|
||||
import { UserService } from './services/user/user.service';
|
||||
@ -60,7 +59,6 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public routerLinkResources = ['/' + $localize`resources`];
|
||||
public showFooter = false;
|
||||
public user: User;
|
||||
public version = environment.version;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
|
@ -116,7 +116,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
type: 'ACCOUNT'
|
||||
}
|
||||
],
|
||||
range: 'max'
|
||||
range: 'max',
|
||||
withExcludedAccounts: true
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ chart }) => {
|
||||
|
@ -9,7 +9,11 @@
|
||||
[showYAxis]="true"
|
||||
[symbol]="symbol"
|
||||
></gf-line-chart>
|
||||
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
|
||||
<div
|
||||
*ngFor="let itemByMonth of marketDataByMonth | keyvalue"
|
||||
class="d-flex"
|
||||
[hidden]="!marketData.length > 0"
|
||||
>
|
||||
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
||||
<div class="align-items-center d-flex flex-grow-1 px-1">
|
||||
<div
|
||||
|
@ -28,7 +28,6 @@
|
||||
|
||||
&.today {
|
||||
background-color: rgba(var(--palette-accent-500), 1);
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -83,10 +83,10 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
public ngOnChanges() {
|
||||
this.defaultDateFormat = getDateFormatString(this.locale);
|
||||
|
||||
this.historicalDataItems = this.marketData.map((marketDataItem) => {
|
||||
this.historicalDataItems = this.marketData.map(({ date, marketPrice }) => {
|
||||
return {
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value: marketDataItem.marketPrice
|
||||
date: format(date, DATE_FORMAT),
|
||||
value: marketPrice
|
||||
};
|
||||
});
|
||||
|
||||
@ -157,10 +157,6 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
const date = parseISO(`${yearMonth}-${day}`);
|
||||
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
|
||||
|
||||
if (isSameDay(date, new Date())) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
||||
data: <MarketDataDetailDialogParams>{
|
||||
date,
|
||||
|
@ -6,7 +6,7 @@ import {
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { FormBuilder } from '@angular/forms';
|
||||
import { FormBuilder, FormControl, Validators } from '@angular/forms';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
@ -17,7 +17,12 @@ import {
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { MarketData, SymbolProfile } from '@prisma/client';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
MarketData,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import { parse as csvToJson } from 'papaparse';
|
||||
import { Subject } from 'rxjs';
|
||||
@ -33,14 +38,23 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
|
||||
styleUrls: ['./asset-profile-dialog.component.scss']
|
||||
})
|
||||
export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
public assetClass: string;
|
||||
public assetProfileClass: string;
|
||||
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
|
||||
return { id: assetClass, label: translate(assetClass) };
|
||||
});
|
||||
public assetSubClasses = Object.keys(AssetSubClass).map((assetSubClass) => {
|
||||
return { id: assetSubClass, label: translate(assetSubClass) };
|
||||
});
|
||||
public assetProfile: AdminMarketDataDetails['assetProfile'];
|
||||
public assetProfileForm = this.formBuilder.group({
|
||||
assetClass: new FormControl<AssetClass>(undefined),
|
||||
assetSubClass: new FormControl<AssetSubClass>(undefined),
|
||||
comment: '',
|
||||
name: ['', Validators.required],
|
||||
scraperConfiguration: '',
|
||||
symbolMapping: ''
|
||||
});
|
||||
public assetSubClass: string;
|
||||
public assetProfileSubClass: string;
|
||||
public benchmarks: Partial<SymbolProfile>[];
|
||||
public countries: {
|
||||
[code: string]: { name: string; value: number };
|
||||
@ -86,8 +100,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
.subscribe(({ assetProfile, marketData }) => {
|
||||
this.assetProfile = assetProfile;
|
||||
|
||||
this.assetClass = translate(this.assetProfile?.assetClass);
|
||||
this.assetSubClass = translate(this.assetProfile?.assetSubClass);
|
||||
this.assetProfileClass = translate(this.assetProfile?.assetClass);
|
||||
this.assetProfileSubClass = translate(this.assetProfile?.assetSubClass);
|
||||
this.countries = {};
|
||||
this.isBenchmark = this.benchmarks.some(({ id }) => {
|
||||
return id === this.assetProfile.id;
|
||||
@ -114,6 +128,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
this.assetProfileForm.setValue({
|
||||
name: this.assetProfile.name,
|
||||
assetClass: this.assetProfile.assetClass,
|
||||
assetSubClass: this.assetProfile.assetSubClass,
|
||||
comment: this.assetProfile?.comment ?? '',
|
||||
scraperConfiguration: JSON.stringify(
|
||||
this.assetProfile?.scraperConfiguration ?? {}
|
||||
@ -204,9 +221,12 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
} catch {}
|
||||
|
||||
const assetProfileData: UpdateAssetProfileDto = {
|
||||
assetClass: this.assetProfileForm.controls['assetClass'].value,
|
||||
assetSubClass: this.assetProfileForm.controls['assetSubClass'].value,
|
||||
comment: this.assetProfileForm.controls['comment'].value ?? null,
|
||||
name: this.assetProfileForm.controls['name'].value,
|
||||
scraperConfiguration,
|
||||
symbolMapping,
|
||||
comment: this.assetProfileForm.controls['comment'].value ?? null
|
||||
symbolMapping
|
||||
};
|
||||
|
||||
this.adminService
|
||||
|
@ -112,7 +112,11 @@
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass"
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[hidden]="!assetProfileClass"
|
||||
[value]="assetProfileClass"
|
||||
>Asset Class</gf-value
|
||||
>
|
||||
</div>
|
||||
@ -120,8 +124,8 @@
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[hidden]="!assetSubClass"
|
||||
[value]="assetSubClass"
|
||||
[hidden]="!assetProfileSubClass"
|
||||
[value]="assetProfileSubClass"
|
||||
>Asset Sub Class</gf-value
|
||||
>
|
||||
</div>
|
||||
@ -174,6 +178,38 @@
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Name</mat-label>
|
||||
<input formControlName="name" matInput type="text" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Asset Class</mat-label>
|
||||
<mat-select formControlName="assetClass">
|
||||
<mat-option [value]="null"></mat-option>
|
||||
<mat-option
|
||||
*ngFor="let assetClass of assetClasses"
|
||||
[value]="assetClass.id"
|
||||
>{{ assetClass.label }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Asset Sub Class</mat-label>
|
||||
<mat-select formControlName="assetSubClass">
|
||||
<mat-option [value]="null"></mat-option>
|
||||
<mat-option
|
||||
*ngFor="let assetSubClass of assetSubClasses"
|
||||
[value]="assetSubClass.id"
|
||||
>{{ assetSubClass.label }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50">
|
||||
<mat-checkbox
|
||||
|
@ -7,6 +7,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
@ -26,6 +27,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
|
||||
MatDialogModule,
|
||||
MatInputModule,
|
||||
MatMenuModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule,
|
||||
TextFieldModule
|
||||
],
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { MatCheckboxChange } from '@angular/material/checkbox';
|
||||
import { environment } from '@ghostfolio/client/../environments/environment';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
|
@ -1,6 +1,5 @@
|
||||
<button
|
||||
*ngIf="deviceType === 'mobile'"
|
||||
class="mt-2"
|
||||
mat-button
|
||||
(click)="onClickCloseButton()"
|
||||
>
|
||||
|
@ -1,7 +1,9 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 0;
|
||||
min-height: 0;
|
||||
padding: 0 !important;
|
||||
|
||||
@media (min-width: 576px) {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
@ -155,16 +155,18 @@
|
||||
[isDate]="true"
|
||||
[locale]="data.locale"
|
||||
[value]="firstBuyDate"
|
||||
>First Buy Date</gf-value
|
||||
>First Activity</gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value
|
||||
i18n
|
||||
size="medium"
|
||||
[locale]="data.locale"
|
||||
[value]="transactionCount"
|
||||
>Transactions</gf-value
|
||||
><ng-container *ngIf="transactionCount === 1">Activity</ng-container
|
||||
><ng-container *ngIf="transactionCount !== 1"
|
||||
>Activities</ng-container
|
||||
></gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
|
@ -2,40 +2,42 @@
|
||||
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Membership</h1>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<gf-membership-card
|
||||
class="mb-5 mx-auto"
|
||||
[expiresAt]="(user?.subscription?.expiresAt | date: defaultDateFormat) ?? '∞'"
|
||||
[name]="user?.subscription?.type"
|
||||
></gf-membership-card>
|
||||
<div class="d-flex">
|
||||
<div class="mx-auto">
|
||||
<div *ngIf="user?.subscription?.type === 'Basic'">
|
||||
<ng-container
|
||||
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
|
||||
>
|
||||
<button color="primary" mat-flat-button (click)="onCheckout()">
|
||||
<ng-container *ngIf="user.subscription.offer === 'default'" i18n
|
||||
>Upgrade</ng-container
|
||||
>
|
||||
<ng-container *ngIf="user.subscription.offer === 'renewal'" i18n
|
||||
>Renew</ng-container
|
||||
>
|
||||
</button>
|
||||
<div *ngIf="price" class="mt-1">
|
||||
<ng-container *ngIf="coupon"
|
||||
><del class="text-muted"
|
||||
>{{ baseCurrency }} {{ price }}</del
|
||||
> {{ baseCurrency }} {{ price - coupon
|
||||
}}</ng-container
|
||||
>
|
||||
<ng-container *ngIf="!coupon"
|
||||
>{{ baseCurrency }} {{ price }}</ng-container
|
||||
> <span i18n>per year</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="align-items-center d-flex flex-column">
|
||||
<gf-membership-card
|
||||
[expiresAt]="user?.subscription?.expiresAt | date: defaultDateFormat"
|
||||
[name]="user?.subscription?.type"
|
||||
></gf-membership-card>
|
||||
<div
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="d-flex flex-column mt-5"
|
||||
>
|
||||
<ng-container
|
||||
*ngIf="hasPermissionForSubscription && hasPermissionToUpdateUserSettings"
|
||||
>
|
||||
<button color="primary" mat-flat-button (click)="onCheckout()">
|
||||
<ng-container *ngIf="user.subscription.offer === 'default'" i18n
|
||||
>Upgrade</ng-container
|
||||
>
|
||||
<ng-container *ngIf="user.subscription.offer === 'renewal'" i18n
|
||||
>Renew</ng-container
|
||||
>
|
||||
</button>
|
||||
<div *ngIf="price" class="mt-1 text-center">
|
||||
<ng-container *ngIf="coupon"
|
||||
><del class="text-muted"
|
||||
>{{ baseCurrency }} {{ price }}</del
|
||||
> {{ baseCurrency }} {{ price - coupon
|
||||
}}</ng-container
|
||||
>
|
||||
<ng-container *ngIf="!coupon"
|
||||
>{{ baseCurrency }} {{ price }}</ng-container
|
||||
> <span i18n>per year</span>
|
||||
</div>
|
||||
</ng-container>
|
||||
<div class="align-items-center d-flex justfiy-content-center mt-4">
|
||||
<a
|
||||
*ngIf="!user?.subscription?.expiresAt"
|
||||
class="mr-2 my-2"
|
||||
class="mx-1"
|
||||
mat-stroked-button
|
||||
[href]="trySubscriptionMail"
|
||||
><span i18n>Try Premium</span>
|
||||
@ -46,7 +48,7 @@
|
||||
></a>
|
||||
<a
|
||||
*ngIf="hasPermissionToUpdateUserSettings"
|
||||
class="mr-2 my-2"
|
||||
class="mx-1"
|
||||
i18n
|
||||
mat-stroked-button
|
||||
[routerLink]=""
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div class="container">
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h1 class="h3 mb-4 text-center">
|
||||
<h1 class="h3 line-height-1 mb-4 text-center">
|
||||
<span class="d-none d-sm-block"
|
||||
><ng-container i18n>Our</ng-container> OSS Friends</span
|
||||
>
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { environment } from '@ghostfolio/client/../environments/environment';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
@ -20,7 +19,6 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
|
||||
public routerLinkFaq = ['/' + $localize`faq`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public user: User;
|
||||
public version = environment.version;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
|
@ -35,9 +35,6 @@
|
||||
title="Contributors to Ghostfolio"
|
||||
>contributors</a
|
||||
>.
|
||||
<ng-container *ngIf="version">
|
||||
This instance is running Ghostfolio {{ version }}.
|
||||
</ng-container>
|
||||
<ng-container *ngIf="hasPermissionForSubscription"
|
||||
>Check the system status at
|
||||
<a href="https://status.ghostfol.io" title="Ghostfolio Status"
|
||||
|
@ -15,6 +15,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { Currency } from '@ghostfolio/common/interfaces/currency.interface';
|
||||
import { Platform } from '@prisma/client';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { map, startWith } from 'rxjs/operators';
|
||||
@ -30,7 +31,7 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
|
||||
})
|
||||
export class CreateOrUpdateAccountDialog implements OnDestroy {
|
||||
public accountForm: FormGroup;
|
||||
public currencies: string[] = [];
|
||||
public currencies: Currency[] = [];
|
||||
public filteredPlatforms: Observable<Platform[]>;
|
||||
public platforms: Platform[];
|
||||
|
||||
@ -46,7 +47,10 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
|
||||
public ngOnInit() {
|
||||
const { currencies, platforms } = this.dataService.fetchInfo();
|
||||
|
||||
this.currencies = currencies;
|
||||
this.currencies = currencies.map((currency) => ({
|
||||
label: currency,
|
||||
value: currency
|
||||
}));
|
||||
this.platforms = platforms;
|
||||
|
||||
this.accountForm = this.formBuilder.group({
|
||||
@ -101,7 +105,7 @@ export class CreateOrUpdateAccountDialog implements OnDestroy {
|
||||
const account: CreateAccountDto | UpdateAccountDto = {
|
||||
balance: this.accountForm.controls['balance'].value,
|
||||
comment: this.accountForm.controls['comment'].value,
|
||||
currency: this.accountForm.controls['currency'].value,
|
||||
currency: this.accountForm.controls['currency'].value?.value,
|
||||
id: this.accountForm.controls['accountId'].value,
|
||||
isExcluded: this.accountForm.controls['isExcluded'].value,
|
||||
name: this.accountForm.controls['name'].value,
|
||||
|
@ -20,11 +20,10 @@
|
||||
<div>
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Currency</mat-label>
|
||||
<mat-select formControlName="currency">
|
||||
<mat-option *ngFor="let currency of currencies" [value]="currency"
|
||||
>{{ currency }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
<gf-currency-selector
|
||||
formControlName="currency"
|
||||
[currencies]="currencies"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div>
|
||||
@ -37,7 +36,7 @@
|
||||
(keydown.enter)="$event.stopPropagation()"
|
||||
/>
|
||||
<span class="ml-2" matTextSuffix
|
||||
>{{ accountForm.controls['currency'].value }}</span
|
||||
>{{ accountForm.controls['currency']?.value?.value }}</span
|
||||
>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
@ -7,8 +7,8 @@ import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { GfSymbolIconModule } from '@ghostfolio/client/components/symbol-icon/symbol-icon.module';
|
||||
import { GfCurrencySelectorModule } from '@ghostfolio/ui/currency-selector/currency-selector.module';
|
||||
|
||||
import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.component';
|
||||
|
||||
@ -17,6 +17,7 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfCurrencySelectorModule,
|
||||
GfSymbolIconModule,
|
||||
MatAutocompleteModule,
|
||||
MatButtonModule,
|
||||
@ -24,7 +25,6 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
ReactiveFormsModule
|
||||
]
|
||||
})
|
||||
|
@ -21,10 +21,7 @@
|
||||
>Stocks, ETFs, bonds, cryptocurrencies, commodities</small
|
||||
>
|
||||
</mat-option>
|
||||
<mat-option
|
||||
*ngIf="data.user?.settings?.isExperimentalFeatures"
|
||||
value="FEE"
|
||||
>
|
||||
<mat-option value="FEE">
|
||||
<span><b>{{ typesTranslationMap['FEE'] }}</b></span>
|
||||
<small class="d-block line-height-1 text-muted text-nowrap" i18n
|
||||
>One-time fee, annual account fees</small
|
||||
@ -36,10 +33,7 @@
|
||||
>Distribution of corporate earnings</small
|
||||
>
|
||||
</mat-option>
|
||||
<mat-option
|
||||
*ngIf="data.user?.settings?.isExperimentalFeatures"
|
||||
value="INTEREST"
|
||||
>
|
||||
<mat-option value="INTEREST">
|
||||
<span><b>{{ typesTranslationMap['INTEREST'] }}</b></span>
|
||||
<small class="d-block line-height-1 text-muted text-nowrap" i18n
|
||||
>Revenue for lending out money</small
|
||||
|
@ -267,6 +267,8 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
|
||||
return;
|
||||
} else if (file.name.endsWith('.csv')) {
|
||||
const content = fileContent.split('\n').slice(1);
|
||||
|
||||
try {
|
||||
const data = await this.importActivitiesService.importCsv({
|
||||
fileContent,
|
||||
@ -277,7 +279,7 @@ export class ImportActivitiesDialog implements OnDestroy {
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.handleImportError({
|
||||
activities: error?.activities ?? [],
|
||||
activities: error?.activities ?? content,
|
||||
error: {
|
||||
error: { message: error?.error?.message ?? [error?.message] }
|
||||
}
|
||||
|
@ -309,7 +309,6 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private update() {
|
||||
this.isLoadingBenchmarkComparator = true;
|
||||
this.isLoadingInvestmentChart = true;
|
||||
|
||||
this.dataService
|
||||
@ -385,35 +384,37 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private updateBenchmarkDataItems() {
|
||||
this.benchmarkDataItems = [];
|
||||
|
||||
if (this.user.settings.benchmark) {
|
||||
const { dataSource, symbol } =
|
||||
this.benchmarks.find(({ id }) => {
|
||||
return id === this.user.settings.benchmark;
|
||||
}) ?? {};
|
||||
|
||||
this.dataService
|
||||
.fetchBenchmarkBySymbol({
|
||||
dataSource,
|
||||
symbol,
|
||||
startDate: this.firstOrderDate
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketData }) => {
|
||||
this.benchmarkDataItems = marketData.map(({ date, value }) => {
|
||||
return {
|
||||
date,
|
||||
value
|
||||
};
|
||||
if (dataSource && symbol) {
|
||||
this.isLoadingBenchmarkComparator = true;
|
||||
|
||||
this.dataService
|
||||
.fetchBenchmarkBySymbol({
|
||||
dataSource,
|
||||
symbol,
|
||||
startDate: this.firstOrderDate
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketData }) => {
|
||||
this.benchmarkDataItems = marketData.map(({ date, value }) => {
|
||||
return {
|
||||
date,
|
||||
value
|
||||
};
|
||||
});
|
||||
|
||||
this.isLoadingBenchmarkComparator = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.isLoadingBenchmarkComparator = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
} else {
|
||||
this.benchmarkDataItems = [];
|
||||
|
||||
this.isLoadingBenchmarkComparator = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@
|
||||
<div *ngIf="user?.settings?.viewMode !== 'ZEN'" class="my-4 text-center">
|
||||
<gf-toggle
|
||||
[defaultValue]="user?.settings?.dateRange"
|
||||
[isLoading]="isLoadingBenchmarkComparator"
|
||||
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
|
||||
[options]="dateRangeOptions"
|
||||
(change)="onChangeDateRange($event.value)"
|
||||
></gf-toggle>
|
||||
@ -23,7 +23,7 @@
|
||||
[benchmarks]="benchmarks"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[daysInMarket]="daysInMarket"
|
||||
[isLoading]="isLoadingBenchmarkComparator"
|
||||
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
|
||||
[locale]="user?.settings?.locale"
|
||||
[performanceDataItems]="performanceDataItemsInPercentage"
|
||||
[user]="user"
|
||||
@ -149,7 +149,7 @@
|
||||
[daysInMarket]="daysInMarket"
|
||||
[historicalDataItems]="performanceDataItems"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[isLoading]="isLoadingBenchmarkComparator"
|
||||
[isLoading]="isLoadingInvestmentChart"
|
||||
[locale]="user?.settings?.locale"
|
||||
[range]="user?.settings?.dateRange"
|
||||
></gf-investment-chart>
|
||||
|
@ -35,15 +35,19 @@
|
||||
its capabilities, security, and user experience.
|
||||
</p>
|
||||
<p i18n>
|
||||
Let’s dive deeper into the detailed comparison table below to gain a
|
||||
thorough understanding of how Ghostfolio positions itself relative
|
||||
to {{ product2.name }}. We will explore various aspects such as
|
||||
features, data privacy, pricing, and more, allowing you to make a
|
||||
well-informed choice for your personal requirements.
|
||||
Let’s dive deeper into the detailed Ghostfolio vs {{ product2.name
|
||||
}} comparison table below to gain a thorough understanding of how
|
||||
Ghostfolio positions itself relative to {{ product2.name }}. We will
|
||||
explore various aspects such as features, data privacy, pricing, and
|
||||
more, allowing you to make a well-informed choice for your personal
|
||||
requirements.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<table class="gf-table w-100">
|
||||
<caption class="text-center" i18n>
|
||||
Ghostfolio vs {{ product2.name }} comparison table
|
||||
</caption>
|
||||
<thead>
|
||||
<tr class="mat-mdc-header-row">
|
||||
<th class="mat-mdc-header-cell px-1 py-2"></th>
|
||||
@ -197,12 +201,13 @@
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<p i18n>
|
||||
Please note that the information provided is based on our
|
||||
independent research and analysis. This website is not affiliated
|
||||
with {{ product2.name }} or any other product mentioned in the
|
||||
comparison. As the landscape of personal finance tools evolves, it
|
||||
is essential to verify any specific details or changes directly from
|
||||
the respective product page. Data needs a refresh? Help us maintain
|
||||
Please note that the information provided in the Ghostfolio vs {{
|
||||
product2.name }} comparison table is based on our independent
|
||||
research and analysis. This website is not affiliated with {{
|
||||
product2.name }} or any other product mentioned in the comparison.
|
||||
As the landscape of personal finance tools evolves, it is essential
|
||||
to verify any specific details or changes directly from the
|
||||
respective product page. Data needs a refresh? Help us maintain
|
||||
accurate data on
|
||||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
|
||||
</p>
|
||||
@ -291,7 +296,7 @@
|
||||
aria-current="page"
|
||||
class="active breadcrumb-item text-truncate"
|
||||
>
|
||||
{{ product2.name }}
|
||||
Ghostfolio vs {{ product2.name }}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Product } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { AltooPageComponent } from './products/altoo-page.component';
|
||||
import { BeanvestPageComponent } from './products/beanvest-page.component';
|
||||
import { CapMonPageComponent } from './products/capmon-page.component';
|
||||
import { CopilotMoneyPageComponent } from './products/copilot-money-page.component';
|
||||
import { DeltaPageComponent } from './products/delta-page.component';
|
||||
@ -28,6 +29,7 @@ import { StocklePageComponent } from './products/stockle-page.component';
|
||||
import { StockMarketEyePageComponent } from './products/stockmarketeye-page.component';
|
||||
import { SumioPageComponent } from './products/sumio-page.component';
|
||||
import { UtlunaPageComponent } from './products/utluna-page.component';
|
||||
import { WealthicaPageComponent } from './products/wealthica-page.component';
|
||||
import { YeekateePageComponent } from './products/yeekatee-page.component';
|
||||
|
||||
export const products: Product[] = [
|
||||
@ -63,6 +65,17 @@ export const products: Product[] = [
|
||||
origin: $localize`Switzerland`,
|
||||
slogan: 'Simplicity for Complex Wealth'
|
||||
},
|
||||
{
|
||||
component: BeanvestPageComponent,
|
||||
founded: 2020,
|
||||
hasFreePlan: true,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'beanvest',
|
||||
name: 'Beanvest',
|
||||
origin: $localize`France`,
|
||||
pricingPerYear: '$100',
|
||||
slogan: 'Stock Portfolio Tracker for Smart Investors'
|
||||
},
|
||||
{
|
||||
component: CapMonPageComponent,
|
||||
founded: 2022,
|
||||
@ -353,6 +366,18 @@ export const products: Product[] = [
|
||||
slogan: 'Your Portfolio. Revealed.',
|
||||
useAnonymously: true
|
||||
},
|
||||
{
|
||||
component: WealthicaPageComponent,
|
||||
founded: 2015,
|
||||
hasFreePlan: true,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'wealthica',
|
||||
languages: ['English', 'Français'],
|
||||
name: 'Wealthica',
|
||||
origin: $localize`Canada`,
|
||||
pricingPerYear: '$50',
|
||||
slogan: 'See all your investments in one place'
|
||||
},
|
||||
{
|
||||
component: YeekateePageComponent,
|
||||
founded: 2021,
|
||||
|
@ -0,0 +1,31 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { products } from '../products';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||
selector: 'gf-beanvest-page',
|
||||
standalone: true,
|
||||
styleUrls: ['../product-page-template.scss'],
|
||||
templateUrl: '../product-page-template.html'
|
||||
})
|
||||
export class BeanvestPageComponent {
|
||||
public product1 = products.find(({ key }) => {
|
||||
return key === 'ghostfolio';
|
||||
});
|
||||
|
||||
public product2 = products.find(({ key }) => {
|
||||
return key === 'beanvest';
|
||||
});
|
||||
|
||||
public routerLinkAbout = ['/' + $localize`about`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public routerLinkResourcesPersonalFinanceTools = [
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { products } from '../products';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
imports: [CommonModule, MatButtonModule, RouterModule],
|
||||
selector: 'gf-wealthica-page',
|
||||
standalone: true,
|
||||
styleUrls: ['../product-page-template.scss'],
|
||||
templateUrl: '../product-page-template.html'
|
||||
})
|
||||
export class WealthicaPageComponent {
|
||||
public product1 = products.find(({ key }) => {
|
||||
return key === 'ghostfolio';
|
||||
});
|
||||
|
||||
public product2 = products.find(({ key }) => {
|
||||
return key === 'wealthica';
|
||||
});
|
||||
|
||||
public routerLinkAbout = ['/' + $localize`about`];
|
||||
public routerLinkFeatures = ['/' + $localize`features`];
|
||||
public routerLinkResourcesPersonalFinanceTools = [
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
];
|
||||
}
|
@ -203,15 +203,25 @@ export class AdminService {
|
||||
}
|
||||
|
||||
public patchAssetProfile({
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: UniqueAsset & UpdateAssetProfileDto) {
|
||||
return this.http.patch<EnhancedSymbolProfile>(
|
||||
`/api/v1/admin/profile-data/${dataSource}/${symbol}`,
|
||||
{ comment, scraperConfiguration, symbolMapping }
|
||||
{
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
symbolMapping
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -386,14 +386,20 @@ export class DataService {
|
||||
|
||||
public fetchPortfolioPerformance({
|
||||
filters,
|
||||
range
|
||||
range,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
range: DateRange;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Observable<PortfolioPerformanceResponse> {
|
||||
let params = this.buildFiltersAsQueryParams({ filters });
|
||||
params = params.append('range', range);
|
||||
|
||||
if (withExcludedAccounts) {
|
||||
params = params.append('withExcludedAccounts', withExcludedAccounts);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<any>(`/api/v2/portfolio/performance`, {
|
||||
params
|
||||
|
@ -286,7 +286,7 @@ export class ImportActivitiesService {
|
||||
|
||||
for (const key of ImportActivitiesService.QUANTITY_KEYS) {
|
||||
if (isFinite(item[key])) {
|
||||
return item[key];
|
||||
return Math.abs(item[key]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -372,7 +372,7 @@ export class ImportActivitiesService {
|
||||
|
||||
for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) {
|
||||
if (isFinite(item[key])) {
|
||||
return item[key];
|
||||
return Math.abs(item[key]);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"createdAt": "2023-10-05T00:00:00.000Z",
|
||||
"createdAt": "2023-10-21T00:00:00.000Z",
|
||||
"data": [
|
||||
{
|
||||
"name": "BoxyHQ",
|
||||
@ -21,11 +21,21 @@
|
||||
"description": "The Open-Source DocuSign Alternative. We aim to earn your trust by enabling you to self-host the platform and examine its inner workings.",
|
||||
"href": "https://documenso.com"
|
||||
},
|
||||
{
|
||||
"name": "dyrector.io",
|
||||
"description": "dyrector.io is an open-source continuous delivery & deployment platform with version management.",
|
||||
"href": "https://dyrector.io"
|
||||
},
|
||||
{
|
||||
"name": "Erxes",
|
||||
"description": "The Open-Source HubSpot Alternative. A single XOS enables to create unique and life-changing experiences that work for all types of business.",
|
||||
"href": "https://erxes.io"
|
||||
},
|
||||
{
|
||||
"name": "Firecamp",
|
||||
"description": "vscode for apis, open-source postman/insomnia alternative",
|
||||
"href": "https://firecamp.io"
|
||||
},
|
||||
{
|
||||
"name": "Formbricks",
|
||||
"description": "Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.",
|
||||
@ -46,6 +56,11 @@
|
||||
"description": "Open-source authentication and user management for the passkey era. Integrated in minutes, for web and mobile apps.",
|
||||
"href": "https://www.hanko.io"
|
||||
},
|
||||
{
|
||||
"name": "Hook0",
|
||||
"description": "Open-Source Webhooks-as-a-service (WaaS) that makes it easy for developers to send webhooks.",
|
||||
"href": "https://www.hook0.com/"
|
||||
},
|
||||
{
|
||||
"name": "HTMX",
|
||||
"description": "HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.",
|
||||
@ -86,11 +101,21 @@
|
||||
"description": "Open-source solution to deploy, scale, and operate your multiplayer game.",
|
||||
"href": "https://rivet.gg"
|
||||
},
|
||||
{
|
||||
"name": "Shelf.nu",
|
||||
"description": "Open Source Asset and Equipment tracking software that lets you create QR asset labels, manage and overview your assets across locations.",
|
||||
"href": "https://www.shelf.nu/"
|
||||
},
|
||||
{
|
||||
"name": "Sniffnet",
|
||||
"description": "Sniffnet is a network monitoring tool to help you easily keep track of your Internet traffic.",
|
||||
"href": "https://www.sniffnet.net"
|
||||
},
|
||||
{
|
||||
"name": "Spark.NET",
|
||||
"description": "The .NET Web Framework for Makers. Build production ready, full-stack web applications fast without sweating the small stuff.",
|
||||
"href": "https://spark-framework.net"
|
||||
},
|
||||
{
|
||||
"name": "Tolgee",
|
||||
"description": "Software localization from A to Z made really easy.",
|
||||
@ -101,16 +126,16 @@
|
||||
"description": "Create long-running Jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.",
|
||||
"href": "https://trigger.dev"
|
||||
},
|
||||
{
|
||||
"name": "Typebot",
|
||||
"description": "Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.",
|
||||
"href": "https://typebot.io"
|
||||
},
|
||||
{
|
||||
"name": "Twenty",
|
||||
"description": "A modern CRM offering the flexibility of open-source, advanced features and sleek design.",
|
||||
"href": "https://twenty.com"
|
||||
},
|
||||
{
|
||||
"name": "Typebot",
|
||||
"description": "Typebot gives you powerful blocks to create unique chat experiences. Embed them anywhere on your apps and start collecting results like magic.",
|
||||
"href": "https://typebot.io"
|
||||
},
|
||||
{
|
||||
"name": "Webiny",
|
||||
"description": "Open-source enterprise-grade serverless CMS. Own your data. Scale effortlessly. Customize everything.",
|
||||
@ -120,11 +145,6 @@
|
||||
"name": "Webstudio",
|
||||
"description": "Webstudio is an open source alternative to Webflow",
|
||||
"href": "https://webstudio.is"
|
||||
},
|
||||
{
|
||||
"name": "Spark.NET",
|
||||
"description": "The .NET Web Framework for Makers. Build production ready, full-stack web applications fast without sweating the small stuff.",
|
||||
"href": "https://spark-framework.net"
|
||||
}
|
||||
],
|
||||
"source": "https://formbricks.com/api/oss-friends"
|
||||
|
@ -1,6 +1,5 @@
|
||||
export const environment = {
|
||||
lastPublish: '{BUILD_TIMESTAMP}',
|
||||
production: true,
|
||||
stripePublicKey: '',
|
||||
version: `v${require('../../../../package.json').version}`
|
||||
stripePublicKey: ''
|
||||
};
|
||||
|
@ -5,8 +5,7 @@
|
||||
export const environment = {
|
||||
lastPublish: null,
|
||||
production: false,
|
||||
stripePublicKey: '',
|
||||
version: 'dev'
|
||||
stripePublicKey: ''
|
||||
};
|
||||
|
||||
/*
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -446,6 +446,12 @@ ngx-skeleton-loader {
|
||||
|
||||
.mat-mdc-menu-panel {
|
||||
.mat-mdc-menu-item {
|
||||
&.font-weight-bold {
|
||||
.mat-mdc-menu-item-text {
|
||||
--mat-menu-item-label-text-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.mdc-list-item__primary-text {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
@ -322,6 +322,15 @@ export function parseDate(date: string): Date | null {
|
||||
return parseISO(date);
|
||||
}
|
||||
|
||||
export function parseSymbol({ dataSource, symbol }: UniqueAsset) {
|
||||
const [ticker, exchange] = symbol.split('.');
|
||||
|
||||
return {
|
||||
ticker,
|
||||
exchange: exchange ?? (dataSource === 'YAHOO' ? 'US' : undefined)
|
||||
};
|
||||
}
|
||||
|
||||
export function prettifySymbol(aSymbol: string): string {
|
||||
return aSymbol?.replace(ghostfolioScraperApiSymbolPrefix, '');
|
||||
}
|
||||
|
4
libs/common/src/lib/interfaces/currency.interface.ts
Normal file
4
libs/common/src/lib/interfaces/currency.interface.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface Currency {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
@ -0,0 +1,21 @@
|
||||
<input
|
||||
autocapitalize="off"
|
||||
autocomplete="off"
|
||||
matInput
|
||||
[formControl]="control"
|
||||
[matAutocomplete]="currencyAutocomplete"
|
||||
/>
|
||||
|
||||
<mat-autocomplete
|
||||
#currencyAutocomplete="matAutocomplete"
|
||||
[displayWith]="displayFn"
|
||||
(optionSelected)="onUpdateCurrency($event)"
|
||||
>
|
||||
<mat-option
|
||||
*ngFor="let currencyItem of filteredCurrencies"
|
||||
class="line-height-1"
|
||||
[value]="currencyItem"
|
||||
>
|
||||
{{ currencyItem.label }}
|
||||
</mat-option>
|
||||
</mat-autocomplete>
|
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
167
libs/ui/src/lib/currency-selector/currency-selector.component.ts
Normal file
167
libs/ui/src/lib/currency-selector/currency-selector.component.ts
Normal file
@ -0,0 +1,167 @@
|
||||
import { FocusMonitor } from '@angular/cdk/a11y';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormControl, FormGroupDirective, NgControl } from '@angular/forms';
|
||||
import {
|
||||
MatAutocomplete,
|
||||
MatAutocompleteSelectedEvent
|
||||
} from '@angular/material/autocomplete';
|
||||
import { MatFormFieldControl } from '@angular/material/form-field';
|
||||
import { MatInput } from '@angular/material/input';
|
||||
import { Currency } from '@ghostfolio/common/interfaces/currency.interface';
|
||||
import { AbstractMatFormField } from '@ghostfolio/ui/shared/abstract-mat-form-field';
|
||||
import { Subject } from 'rxjs';
|
||||
import { map, startWith, takeUntil } from 'rxjs/operators';
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
'[attr.aria-describedBy]': 'describedBy',
|
||||
'[id]': 'id'
|
||||
},
|
||||
providers: [
|
||||
{
|
||||
provide: MatFormFieldControl,
|
||||
useExisting: CurrencySelectorComponent
|
||||
}
|
||||
],
|
||||
selector: 'gf-currency-selector',
|
||||
styleUrls: ['./currency-selector.component.scss'],
|
||||
templateUrl: 'currency-selector.component.html'
|
||||
})
|
||||
export class CurrencySelectorComponent
|
||||
extends AbstractMatFormField<Currency>
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
@Input() private currencies: Currency[] = [];
|
||||
@Input() private formControlName: string;
|
||||
|
||||
@ViewChild(MatInput) private input: MatInput;
|
||||
|
||||
@ViewChild('currencyAutocomplete')
|
||||
public currencyAutocomplete: MatAutocomplete;
|
||||
|
||||
public control = new FormControl();
|
||||
public filteredCurrencies: Currency[] = [];
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
public readonly _elementRef: ElementRef,
|
||||
public readonly _focusMonitor: FocusMonitor,
|
||||
public readonly changeDetectorRef: ChangeDetectorRef,
|
||||
private readonly formGroupDirective: FormGroupDirective,
|
||||
public readonly ngControl: NgControl
|
||||
) {
|
||||
super(_elementRef, _focusMonitor, ngControl);
|
||||
|
||||
this.controlType = 'currency-selector';
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
if (this.disabled) {
|
||||
this.control.disable();
|
||||
}
|
||||
|
||||
const formGroup = this.formGroupDirective.form;
|
||||
|
||||
if (formGroup) {
|
||||
const control = formGroup.get(this.formControlName);
|
||||
|
||||
if (control) {
|
||||
this.value = this.currencies.find(({ value }) => {
|
||||
return value === control.value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.control.valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
if (super.value?.value) {
|
||||
super.value.value = null;
|
||||
}
|
||||
});
|
||||
|
||||
this.control.valueChanges
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeSubject),
|
||||
startWith(''),
|
||||
map((value) => {
|
||||
return value ? this.filter(value) : this.currencies.slice();
|
||||
})
|
||||
)
|
||||
.subscribe((values) => {
|
||||
this.filteredCurrencies = values;
|
||||
});
|
||||
}
|
||||
|
||||
public displayFn(currency: Currency) {
|
||||
return currency?.label ?? '';
|
||||
}
|
||||
|
||||
public get empty() {
|
||||
return this.input?.empty;
|
||||
}
|
||||
|
||||
public focus() {
|
||||
this.input.focus();
|
||||
}
|
||||
|
||||
public ngDoCheck() {
|
||||
if (this.ngControl) {
|
||||
this.validateRequired();
|
||||
this.errorState = this.ngControl.invalid && this.ngControl.touched;
|
||||
this.stateChanges.next();
|
||||
}
|
||||
}
|
||||
|
||||
public onUpdateCurrency(event: MatAutocompleteSelectedEvent) {
|
||||
super.value = {
|
||||
label: event.option.value.label,
|
||||
value: event.option.value.value
|
||||
} as Currency;
|
||||
}
|
||||
|
||||
public set value(value: Currency) {
|
||||
const newValue =
|
||||
typeof value === 'object' && value !== null ? { ...value } : value;
|
||||
this.control.setValue(newValue);
|
||||
super.value = newValue;
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
super.ngOnDestroy();
|
||||
|
||||
this.unsubscribeSubject.next();
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private filter(value: Currency | string) {
|
||||
const filterValue =
|
||||
typeof value === 'string'
|
||||
? value?.toLowerCase()
|
||||
: value?.value.toLowerCase();
|
||||
|
||||
return this.currencies.filter((currency) => {
|
||||
return currency.value.toLowerCase().startsWith(filterValue);
|
||||
});
|
||||
}
|
||||
|
||||
private validateRequired() {
|
||||
const requiredCheck = super.required
|
||||
? !super.value.label || !super.value.value
|
||||
: false;
|
||||
|
||||
if (requiredCheck) {
|
||||
this.ngControl.control.setErrors({ invalidData: true });
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,23 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatAutocompleteModule } from '@angular/material/autocomplete';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
|
||||
import { CurrencySelectorComponent } from './currency-selector.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [CurrencySelectorComponent],
|
||||
exports: [CurrencySelectorComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatAutocompleteModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
ReactiveFormsModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class GfCurrencySelectorModule {}
|
@ -1,8 +1,4 @@
|
||||
<span class="align-items-center d-flex"
|
||||
><span
|
||||
class="d-inline-block logo"
|
||||
[ngClass]="{ 'mr-1': showLabel }"
|
||||
[ngStyle]="{ 'background-color': color }"
|
||||
></span>
|
||||
><span class="d-inline-block logo" [ngClass]="{ 'mr-1': showLabel }"></span>
|
||||
<span *ngIf="showLabel" class="label">{{ label ?? 'Ghostfolio' }}</span></span
|
||||
>
|
||||
|
@ -4,18 +4,12 @@
|
||||
}
|
||||
|
||||
.logo {
|
||||
background-color: rgba(var(--dark-primary-text));
|
||||
background-color: currentColor;
|
||||
margin-top: -2px;
|
||||
mask: url('/assets/ghost.svg') no-repeat center;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
.logo {
|
||||
background-color: rgba(var(--light-primary-text));
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.large) {
|
||||
.label {
|
||||
font-size: 3rem;
|
||||
|
@ -13,7 +13,6 @@ import {
|
||||
})
|
||||
export class LogoComponent {
|
||||
@HostBinding('class') @Input() size: 'large' | 'medium' = 'medium';
|
||||
@Input() color: string;
|
||||
@Input() label: string;
|
||||
@Input() showLabel = true;
|
||||
|
||||
|
@ -1,25 +1,29 @@
|
||||
<a
|
||||
class="card-item d-flex flex-column justify-content-between p-4"
|
||||
<div
|
||||
class="card-container position-relative"
|
||||
[ngClass]="{ premium: name === 'Premium' }"
|
||||
[routerLink]="routerLinkPricing"
|
||||
>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-logo
|
||||
color="rgba(255, 255, 255, 1)"
|
||||
size="large"
|
||||
[showLabel]="false"
|
||||
></gf-logo>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<div class="card-item-heading mb-1 text-muted" i18n>Membership</div>
|
||||
<div class="card-item-name line-height-1 text-truncate">{{ name }}</div>
|
||||
<a
|
||||
class="card-item d-flex flex-column justify-content-between p-4"
|
||||
[routerLink]="routerLinkPricing"
|
||||
>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-logo
|
||||
size="large"
|
||||
[ngClass]="{ 'text-muted': name === 'Basic' }"
|
||||
[showLabel]="false"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="card-item-heading mb-1 text-muted" i18n>Valid until</div>
|
||||
<div class="card-item-name line-height-1 text-truncate">
|
||||
{{ expiresAt }}
|
||||
<div class="d-flex justify-content-between">
|
||||
<div>
|
||||
<div class="heading text-muted" i18n>Membership</div>
|
||||
<div class="text-truncate value">{{ name }}</div>
|
||||
</div>
|
||||
<div *ngIf="expiresAt">
|
||||
<div class="heading text-muted" i18n>Valid until</div>
|
||||
<div class="text-truncate value">
|
||||
{{ expiresAt }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</a>
|
||||
</div>
|
||||
|
@ -1,24 +1,66 @@
|
||||
:host {
|
||||
--borderRadius: 1rem;
|
||||
--borderWidth: 2px;
|
||||
|
||||
display: block;
|
||||
max-width: 25rem;
|
||||
padding-top: calc(1 * var(--borderWidth));
|
||||
width: 100%;
|
||||
|
||||
.card-item {
|
||||
aspect-ratio: 1.586;
|
||||
background-color: #343a40 !important;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.3);
|
||||
.card-container {
|
||||
border-radius: var(--borderRadius);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15);
|
||||
|
||||
&.premium {
|
||||
background-color: #1d2124 !important;
|
||||
&:after {
|
||||
animation: animatedborder 7s ease alternate infinite;
|
||||
background: linear-gradient(60deg, #5073b8, #1098ad, #07b39b, #6fba82);
|
||||
background-size: 300% 300%;
|
||||
border-radius: var(--borderRadius);
|
||||
content: '';
|
||||
height: calc(100% + var(--borderWidth) * 2);
|
||||
left: calc(-1 * var(--borderWidth));
|
||||
top: calc(-1 * var(--borderWidth));
|
||||
position: absolute;
|
||||
width: calc(100% + var(--borderWidth) * 2);
|
||||
z-index: -1;
|
||||
|
||||
@keyframes animatedborder {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-item-heading {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.card-item-name {
|
||||
.card-item {
|
||||
aspect-ratio: 1.586;
|
||||
background-color: #1d2124;
|
||||
border-radius: calc(var(--borderRadius) - var(--borderWidth));
|
||||
color: rgba(var(--light-primary-text));
|
||||
font-size: 18px;
|
||||
|
||||
.heading {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.premium) {
|
||||
&:after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.card-item {
|
||||
background-color: #ffffff;
|
||||
color: rgba(var(--dark-primary-text));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,6 +19,7 @@ import { MatInput } from '@angular/material/input';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { AbstractMatFormField } from '@ghostfolio/ui/shared/abstract-mat-form-field';
|
||||
import { isString } from 'lodash';
|
||||
import { Subject, tap } from 'rxjs';
|
||||
import {
|
||||
@ -29,8 +30,6 @@ import {
|
||||
takeUntil
|
||||
} from 'rxjs/operators';
|
||||
|
||||
import { AbstractMatFormField } from './abstract-mat-form-field';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
@ -54,7 +53,7 @@ export class SymbolAutocompleteComponent
|
||||
@Input() private includeIndices = false;
|
||||
@Input() public isLoading = false;
|
||||
|
||||
@ViewChild(MatInput, { static: false }) private input: MatInput;
|
||||
@ViewChild(MatInput) private input: MatInput;
|
||||
|
||||
@ViewChild('symbolAutocomplete') public symbolAutocomplete: MatAutocomplete;
|
||||
|
||||
|
36
nx.json
36
nx.json
@ -2,23 +2,6 @@
|
||||
"affected": {
|
||||
"defaultBase": "origin/main"
|
||||
},
|
||||
"npmScope": "ghostfolio",
|
||||
"tasksRunnerOptions": {
|
||||
"default": {
|
||||
"runner": "nx-cloud",
|
||||
"options": {
|
||||
"accessToken": "Mjg0ZGQ2YjAtNGI4NS00NmYwLThhOWEtMWZmNmQzODM4YzU4fHJlYWQ=",
|
||||
"cacheableOperations": [
|
||||
"build",
|
||||
"lint",
|
||||
"test",
|
||||
"e2e",
|
||||
"build-storybook"
|
||||
],
|
||||
"parallel": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"defaultProject": "api",
|
||||
"generators": {
|
||||
"@nx/angular:application": {
|
||||
@ -37,13 +20,16 @@
|
||||
"targetDefaults": {
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"inputs": ["production", "^production"]
|
||||
"inputs": ["production", "^production"],
|
||||
"cache": true
|
||||
},
|
||||
"e2e": {
|
||||
"inputs": ["default", "^production"]
|
||||
"inputs": ["default", "^production"],
|
||||
"cache": true
|
||||
},
|
||||
"test": {
|
||||
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"]
|
||||
"inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"],
|
||||
"cache": true
|
||||
},
|
||||
"build-storybook": {
|
||||
"inputs": [
|
||||
@ -52,7 +38,11 @@
|
||||
"{workspaceRoot}/.storybook/**/*",
|
||||
"{projectRoot}/.storybook/**/*",
|
||||
"{projectRoot}/tsconfig.storybook.json"
|
||||
]
|
||||
],
|
||||
"cache": true
|
||||
},
|
||||
"lint": {
|
||||
"cache": true
|
||||
}
|
||||
},
|
||||
"namedInputs": {
|
||||
@ -72,5 +62,7 @@
|
||||
"!{projectRoot}/tsconfig.storybook.json",
|
||||
"!{projectRoot}/src/test-setup.[jt]s"
|
||||
]
|
||||
}
|
||||
},
|
||||
"nxCloudAccessToken": "Mjg0ZGQ2YjAtNGI4NS00NmYwLThhOWEtMWZmNmQzODM4YzU4fHJlYWQ=",
|
||||
"parallel": 1
|
||||
}
|
||||
|
35
package.json
35
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "2.13.0",
|
||||
"version": "2.15.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"repository": "https://github.com/ghostfolio/ghostfolio",
|
||||
@ -128,8 +128,8 @@
|
||||
"stripe": "11.12.0",
|
||||
"svgmap": "2.6.0",
|
||||
"twitter-api-v2": "1.14.2",
|
||||
"uuid": "9.0.0",
|
||||
"yahoo-finance2": "2.8.0",
|
||||
"uuid": "9.0.1",
|
||||
"yahoo-finance2": "2.8.1",
|
||||
"zone.js": "0.13.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
@ -146,21 +146,21 @@
|
||||
"@angular/pwa": "16.2.0",
|
||||
"@nestjs/schematics": "10.0.1",
|
||||
"@nestjs/testing": "10.1.3",
|
||||
"@nx/angular": "16.7.4",
|
||||
"@nx/cypress": "16.7.4",
|
||||
"@nx/eslint-plugin": "16.7.4",
|
||||
"@nx/jest": "16.7.4",
|
||||
"@nx/js": "16.7.4",
|
||||
"@nx/nest": "16.7.4",
|
||||
"@nx/node": "16.7.4",
|
||||
"@nx/storybook": "16.7.4",
|
||||
"@nx/web": "16.7.4",
|
||||
"@nx/workspace": "16.7.4",
|
||||
"@nx/angular": "17.0.2",
|
||||
"@nx/cypress": "17.0.2",
|
||||
"@nx/eslint-plugin": "17.0.2",
|
||||
"@nx/jest": "17.0.2",
|
||||
"@nx/js": "17.0.2",
|
||||
"@nx/nest": "17.0.2",
|
||||
"@nx/node": "17.0.2",
|
||||
"@nx/storybook": "17.0.2",
|
||||
"@nx/web": "17.0.2",
|
||||
"@nx/workspace": "17.0.2",
|
||||
"@schematics/angular": "16.2.0",
|
||||
"@simplewebauthn/typescript-types": "8.0.0",
|
||||
"@storybook/addon-essentials": "7.3.2",
|
||||
"@storybook/angular": "7.3.2",
|
||||
"@storybook/core-server": "7.3.2",
|
||||
"@storybook/addon-essentials": "7.5.1",
|
||||
"@storybook/angular": "7.5.1",
|
||||
"@storybook/core-server": "7.5.1",
|
||||
"@types/big.js": "6.1.6",
|
||||
"@types/body-parser": "1.19.2",
|
||||
"@types/cache-manager": "3.4.2",
|
||||
@ -187,8 +187,7 @@
|
||||
"jest": "29.4.3",
|
||||
"jest-environment-jsdom": "29.4.3",
|
||||
"jest-preset-angular": "13.1.1",
|
||||
"nx": "16.7.4",
|
||||
"nx-cloud": "16.3.0",
|
||||
"nx": "17.0.2",
|
||||
"prettier": "3.0.3",
|
||||
"prettier-plugin-organize-attributes": "1.0.0",
|
||||
"react": "18.2.0",
|
||||
|
@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "SymbolProfile"
|
||||
ADD COLUMN "figi" TEXT,
|
||||
ADD COLUMN "figiComposite" TEXT,
|
||||
ADD COLUMN "figiShareClass" TEXT;
|
@ -132,6 +132,9 @@ model SymbolProfile {
|
||||
createdAt DateTime @default(now())
|
||||
currency String
|
||||
dataSource DataSource
|
||||
figi String?
|
||||
figiComposite String?
|
||||
figiShareClass String?
|
||||
id String @id @default(uuid())
|
||||
isin String?
|
||||
name String?
|
||||
|
5
test/import/invalid-multi-line.csv
Normal file
5
test/import/invalid-multi-line.csv
Normal file
@ -0,0 +1,5 @@
|
||||
Date,Code,Currency,Price,Quantity,Action,Fee,Note
|
||||
16-09-2021,MSFT,USD,298.580,5,buy,19.00,My first order 🤓
|
||||
17/11/2021,MSFT,USD,0.62,5,dividend,0.00
|
||||
01.01.2022,Penthouse Apartment,USD,500000.0,1,<invalid>,0.00
|
||||
20500606,MSFT,USD,0.00,0,buy,0.00
|
|
Reference in New Issue
Block a user