Compare commits
32 Commits
Author | SHA1 | Date | |
---|---|---|---|
830569b38e | |||
35b4aef06f | |||
bc2fd9c970 | |||
c42a8aebed | |||
fad1adb91b | |||
9cd37f8de0 | |||
d49b90d7a5 | |||
130a9ea062 | |||
ffc6309850 | |||
976cc7f243 | |||
7067aca04b | |||
1c9805bb96 | |||
8227a2d91a | |||
194aee97db | |||
0f77169952 | |||
0f8dc62c53 | |||
554136cdcd | |||
83b5cfff1f | |||
dcec3accf0 | |||
f08b0b570b | |||
8386fec98a | |||
4d3dff3e5b | |||
76890e63fa | |||
4fb2aebf4f | |||
ed5cd3b978 | |||
469c1936b4 | |||
8b3cc5c11a | |||
ee086638f3 | |||
58d1abbd38 | |||
ba979cbae2 | |||
8cda43bb63 | |||
c4499df74c |
52
CHANGELOG.md
52
CHANGELOG.md
@ -5,6 +5,58 @@ 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.93.0 - 2024-07-07
|
||||
|
||||
### Added
|
||||
|
||||
- Added the _Crypto Coins Heatmap_ to the resources section
|
||||
- Added the _Stock Heatmap_ to the resources section
|
||||
- Extended the content of the _Self-Hosting_ section by the platforms concept on the Frequently Asked Questions (FAQ) page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the allocations by ETF holding on the allocations page for the impersonation mode (experimental)
|
||||
- Improved the detection of REST APIs (`JSON`) used via the scraper configuration
|
||||
- Improved the usability to delete an asset profile of type currency in the historical market data table and the asset profile details dialog of the admin control
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Refactored the thresholds of the rules in the _X-ray_ section
|
||||
- Removed the obsolete `version` from the `docker-compose` files
|
||||
- Upgraded `Nx` from version `19.2.2` to `19.4.0`
|
||||
|
||||
## 2.92.0 - 2024-06-30
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for bulk deletion of asset profiles from the market data table in the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Added support for derived currencies in the currency validation
|
||||
- Added support for automatic deletion of unused asset profiles when deleting activities
|
||||
- Improved the caching of the benchmarks in the markets overview (only cache if needed)
|
||||
- Upgraded `prisma` from version `5.15.0` to `5.16.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the all time high in the benchmarks of the markets overview
|
||||
|
||||
## 2.91.0 - 2024-06-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added a benchmarks preset to the historical market data table of the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `angular` from version `18.0.2` to `18.0.4`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the dialog position (center) on mobile
|
||||
- Fixed the horizontal overflow in the historical market data table of the admin control panel
|
||||
- Changed the mechanism of the `INTRADAY` data gathering to persist data only if the market state is `OPEN`
|
||||
- Fixed the creation of activities with `MANUAL` data source (with no historical market data)
|
||||
|
||||
## 2.90.0 - 2024-06-22
|
||||
|
||||
### Added
|
||||
|
@ -10,7 +10,7 @@ Remove permission in `UserService` using `without()`
|
||||
|
||||
### Frontend
|
||||
|
||||
Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
|
||||
|
||||
## Git
|
||||
|
||||
|
@ -7,7 +7,7 @@
|
||||
**Open Source Wealth Management Software**
|
||||
|
||||
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://twitter.com/ghostfolio_)
|
||||
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://x.com/ghostfolio_)
|
||||
|
||||
[](https://www.buymeacoffee.com/ghostfolio)
|
||||
[](#contributing)
|
||||
@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
@ -89,7 +89,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | string (`optional`) | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
|
||||
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API key |
|
||||
| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
@ -275,7 +275,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
||||
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||
|
||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -20,7 +21,7 @@ export class CreateAccountDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsOptional()
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -20,7 +21,7 @@ export class UpdateAccountDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
@ -20,6 +21,7 @@ import { QueueModule } from './queue/queue.module';
|
||||
@Module({
|
||||
imports: [
|
||||
ApiModule,
|
||||
BenchmarkModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
@ -41,6 +42,7 @@ import { groupBy } from 'lodash';
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
@ -151,7 +153,16 @@ export class AdminService {
|
||||
[{ symbol: 'asc' }];
|
||||
const where: Prisma.SymbolProfileWhereInput = {};
|
||||
|
||||
if (presetId === 'CURRENCIES') {
|
||||
if (presetId === 'BENCHMARKS') {
|
||||
const benchmarkAssetProfiles =
|
||||
await this.benchmarkService.getBenchmarkAssetProfiles();
|
||||
|
||||
where.id = {
|
||||
in: benchmarkAssetProfiles.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
};
|
||||
} else if (presetId === 'CURRENCIES') {
|
||||
return this.getMarketDataForCurrencies();
|
||||
} else if (
|
||||
presetId === 'ETF_WITHOUT_COUNTRIES' ||
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
|
||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -26,7 +27,7 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
countries?: Prisma.InputJsonArray;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
|
@ -135,7 +135,7 @@ export class BenchmarkService {
|
||||
Promise.all(promisesAllTimeHighs),
|
||||
Promise.all(promisesBenchmarkTrends)
|
||||
]);
|
||||
let storeInCache = true;
|
||||
let storeInCache = useCache;
|
||||
|
||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||
const { marketPrice } =
|
||||
@ -161,7 +161,10 @@ export class BenchmarkService {
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
date: allTimeHigh?.date,
|
||||
performancePercent: performancePercentFromAllTimeHigh
|
||||
performancePercent:
|
||||
performancePercentFromAllTimeHigh >= 0
|
||||
? 0
|
||||
: performancePercentFromAllTimeHigh
|
||||
}
|
||||
},
|
||||
symbol: benchmarkAssetProfiles[index].symbol,
|
||||
@ -419,7 +422,7 @@ export class BenchmarkService {
|
||||
private getMarketCondition(
|
||||
aPerformanceInPercent: number
|
||||
): Benchmark['marketCondition'] {
|
||||
if (aPerformanceInPercent === 0) {
|
||||
if (aPerformanceInPercent >= 0) {
|
||||
return 'ALL_TIME_HIGH';
|
||||
} else if (aPerformanceInPercent <= -0.2) {
|
||||
return 'BEAR_MARKET';
|
||||
|
@ -7,7 +7,6 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration/conf
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
|
||||
|
||||
import {
|
||||
@ -12,7 +13,6 @@ import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@ -42,10 +42,10 @@ export class CreateOrderDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
@IsOptional()
|
||||
customCurrency?: string;
|
||||
|
||||
|
@ -66,7 +66,6 @@ export class OrderController {
|
||||
|
||||
return this.orderService.deleteOrders({
|
||||
filters,
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
@ -184,7 +184,15 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
|
||||
const [symbolProfile] =
|
||||
await this.symbolProfileService.getSymbolProfilesByIds([
|
||||
order.symbolProfileId
|
||||
]);
|
||||
|
||||
if (
|
||||
['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type) ||
|
||||
symbolProfile.activitiesCount === 0
|
||||
) {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
@ -200,18 +208,16 @@ export class OrderService {
|
||||
|
||||
public async deleteOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<number> {
|
||||
const { activities } = await this.getOrders({
|
||||
filters,
|
||||
userId,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
userCurrency: undefined,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
@ -225,6 +231,19 @@ export class OrderService {
|
||||
}
|
||||
});
|
||||
|
||||
const symbolProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesByIds(
|
||||
activities.map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
})
|
||||
);
|
||||
|
||||
for (const { activitiesCount, id } of symbolProfiles) {
|
||||
if (activitiesCount === 0) {
|
||||
await this.symbolProfileService.deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({ userId })
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970';
|
||||
|
||||
import {
|
||||
@ -11,7 +12,6 @@ import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@ -41,10 +41,10 @@ export class UpdateOrderDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
@IsOptional()
|
||||
customCurrency?: string;
|
||||
|
||||
|
@ -499,7 +499,17 @@ export class PortfolioService {
|
||||
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
|
||||
grossPerformanceWithCurrencyEffect:
|
||||
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
|
||||
holdings: assetProfile.holdings,
|
||||
holdings: assetProfile.holdings.map(
|
||||
({ allocationInPercentage, name }) => {
|
||||
return {
|
||||
allocationInPercentage,
|
||||
name,
|
||||
valueInBaseCurrency: valueInBaseCurrency
|
||||
.mul(allocationInPercentage)
|
||||
.toNumber()
|
||||
};
|
||||
}
|
||||
),
|
||||
investment: investment.toNumber(),
|
||||
marketState: dataProviderResponse?.marketState ?? 'delayed',
|
||||
name: assetProfile.name,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
|
||||
import type {
|
||||
ColorScheme,
|
||||
DateRange,
|
||||
@ -7,7 +8,6 @@ import type {
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
@ -21,7 +21,7 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
annualInterestRate?: number;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsCurrencyCode()
|
||||
@IsOptional()
|
||||
baseCurrency?: string;
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
@ -19,6 +20,7 @@ import { UserService } from './user.service';
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
}),
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
SubscriptionModule,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
@ -40,6 +41,7 @@ export class UserService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
@ -398,8 +400,8 @@ export class UserService {
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await this.prismaService.order.deleteMany({
|
||||
where: { userId: where.id }
|
||||
await this.orderService.deleteOrders({
|
||||
userId: where.id
|
||||
});
|
||||
} catch {}
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -55,10 +55,10 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
|
||||
|
||||
if (maxInvestmentRatio > ruleSettings.threshold) {
|
||||
if (maxInvestmentRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `Over ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your current investment is at ${maxItem.name} (${(
|
||||
maxInvestmentRatio * 100
|
||||
).toPrecision(3)}%)`,
|
||||
@ -70,7 +70,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
evaluation: `The major part of your current investment is at ${
|
||||
maxItem.name
|
||||
} (${(maxInvestmentRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}%`,
|
||||
value: true
|
||||
};
|
||||
@ -80,12 +80,12 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
thresholdMax: 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
thresholdMax: number;
|
||||
}
|
||||
|
@ -41,10 +41,10 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
const maxValueRatio = maxItem?.value / totalValue || 0;
|
||||
|
||||
if (maxValueRatio > ruleSettings.threshold) {
|
||||
if (maxValueRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `Over ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your current investment is in ${maxItem.groupKey} (${(
|
||||
maxValueRatio * 100
|
||||
).toPrecision(3)}%)`,
|
||||
@ -56,7 +56,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
evaluation: `The major part of your current investment is in ${
|
||||
maxItem?.groupKey ?? ruleSettings.baseCurrency
|
||||
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}%`,
|
||||
value: true
|
||||
};
|
||||
@ -66,12 +66,12 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.5
|
||||
thresholdMax: 0.5
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
thresholdMax: number;
|
||||
}
|
||||
|
@ -19,16 +19,16 @@ export class EmergencyFundSetup extends Rule<Settings> {
|
||||
}
|
||||
|
||||
public evaluate(ruleSettings: Settings) {
|
||||
if (this.emergencyFund > ruleSettings.threshold) {
|
||||
if (this.emergencyFund < ruleSettings.thresholdMin) {
|
||||
return {
|
||||
evaluation: 'An emergency fund has been set up',
|
||||
value: true
|
||||
evaluation: 'No emergency fund has been set up',
|
||||
value: false
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evaluation: 'No emergency fund has been set up',
|
||||
value: false
|
||||
evaluation: 'An emergency fund has been set up',
|
||||
value: true
|
||||
};
|
||||
}
|
||||
|
||||
@ -36,12 +36,12 @@ export class EmergencyFundSetup extends Rule<Settings> {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0
|
||||
thresholdMin: 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
thresholdMin: number;
|
||||
}
|
||||
|
@ -26,10 +26,10 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
? this.fees / this.totalInvestment
|
||||
: 0;
|
||||
|
||||
if (feeRatio > ruleSettings.threshold) {
|
||||
if (feeRatio > ruleSettings.thresholdMax) {
|
||||
return {
|
||||
evaluation: `The fees do exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
||||
value: false
|
||||
};
|
||||
@ -37,7 +37,7 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
|
||||
return {
|
||||
evaluation: `The fees do not exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
ruleSettings.thresholdMax * 100
|
||||
}% of your initial investment (${(feeRatio * 100).toPrecision(3)}%)`,
|
||||
value: true
|
||||
};
|
||||
@ -47,12 +47,12 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
return {
|
||||
baseCurrency: aUserSettings.baseCurrency,
|
||||
isActive: true,
|
||||
threshold: 0.01
|
||||
thresholdMax: 0.01
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
thresholdMax: number;
|
||||
}
|
||||
|
@ -516,7 +516,8 @@ export class DataProviderService {
|
||||
.filter((symbol) => {
|
||||
return (
|
||||
isNumber(response[symbol].marketPrice) &&
|
||||
response[symbol].marketPrice > 0
|
||||
response[symbol].marketPrice > 0 &&
|
||||
response[symbol].marketState === 'open'
|
||||
);
|
||||
})
|
||||
.map((symbol) => {
|
||||
|
@ -246,7 +246,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
for (const { close, code, timestamp } of quotes) {
|
||||
let currency: string;
|
||||
|
||||
if (code.endsWith('.FOREX')) {
|
||||
if (this.isForex(code)) {
|
||||
currency = this.convertFromEodSymbol(code)?.replace(
|
||||
DEFAULT_CURRENCY,
|
||||
''
|
||||
@ -272,7 +272,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
currency,
|
||||
dataSource: this.getName(),
|
||||
marketPrice: close,
|
||||
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||
marketState:
|
||||
this.isForex(code) || isToday(new Date(timestamp * 1000))
|
||||
? 'open'
|
||||
: 'closed'
|
||||
};
|
||||
} else {
|
||||
Logger.error(
|
||||
@ -311,7 +314,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
items: searchResult
|
||||
.filter(({ currency, symbol }) => {
|
||||
// Remove 'NA' currency and exchange rates
|
||||
return currency?.length === 3 && !symbol.endsWith('.FOREX');
|
||||
return currency?.length === 3 && !this.isForex(symbol);
|
||||
})
|
||||
.map(
|
||||
({
|
||||
@ -349,7 +352,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
private convertFromEodSymbol(aEodSymbol: string) {
|
||||
let symbol = aEodSymbol;
|
||||
|
||||
if (symbol.endsWith('.FOREX')) {
|
||||
if (this.isForex(symbol)) {
|
||||
symbol = symbol.replace('GBX', 'GBp');
|
||||
symbol = symbol.replace('.FOREX', '');
|
||||
}
|
||||
@ -451,6 +454,10 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return searchResult;
|
||||
}
|
||||
|
||||
private isForex(aCode: string) {
|
||||
return aCode?.endsWith('.FOREX') || false;
|
||||
}
|
||||
|
||||
private parseAssetClass({
|
||||
Exchange,
|
||||
Type
|
||||
|
@ -167,9 +167,10 @@ export class ManualService implements DataProviderInterface {
|
||||
});
|
||||
|
||||
for (const { currency, symbol } of symbolProfiles) {
|
||||
let marketPrice = marketData.find((marketDataItem) => {
|
||||
return marketDataItem.symbol === symbol;
|
||||
})?.marketPrice;
|
||||
let marketPrice =
|
||||
marketData.find((marketDataItem) => {
|
||||
return marketDataItem.symbol === symbol;
|
||||
})?.marketPrice ?? 0;
|
||||
|
||||
response[symbol] = {
|
||||
currency,
|
||||
@ -256,7 +257,7 @@ export class ManualService implements DataProviderInterface {
|
||||
signal: abortController.signal
|
||||
});
|
||||
|
||||
if (headers['content-type'] === 'application/json') {
|
||||
if (headers['content-type'].includes('application/json')) {
|
||||
const data = JSON.parse(body);
|
||||
const value = String(
|
||||
jsonpath.query(data, scraperConfiguration.selector)[0]
|
||||
|
@ -221,8 +221,9 @@ export class SymbolProfileService {
|
||||
const { name, weight } = holding as Prisma.JsonObject;
|
||||
|
||||
return {
|
||||
allocationInPercentage: weight as number,
|
||||
name: (name as string) ?? UNKNOWN_KEY,
|
||||
valueInBaseCurrency: weight as number
|
||||
valueInBaseCurrency: undefined
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -70,7 +70,7 @@ export class TwitterBotService {
|
||||
await this.twitterClient.v2.tweet(status);
|
||||
|
||||
Logger.log(
|
||||
`Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`,
|
||||
`Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`,
|
||||
'TwitterBotService'
|
||||
);
|
||||
}
|
||||
|
44
apps/api/src/validators/is-currency-code.ts
Normal file
44
apps/api/src/validators/is-currency-code.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { DERIVED_CURRENCIES } from '@ghostfolio/common/config';
|
||||
|
||||
import {
|
||||
registerDecorator,
|
||||
ValidationOptions,
|
||||
ValidatorConstraint,
|
||||
ValidatorConstraintInterface,
|
||||
ValidationArguments
|
||||
} from 'class-validator';
|
||||
import { isISO4217CurrencyCode } from 'class-validator';
|
||||
|
||||
export function IsCurrencyCode(validationOptions?: ValidationOptions) {
|
||||
return function (object: Object, propertyName: string) {
|
||||
registerDecorator({
|
||||
propertyName,
|
||||
constraints: [],
|
||||
options: validationOptions,
|
||||
target: object.constructor,
|
||||
validator: IsExtendedCurrencyConstraint
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ async: false })
|
||||
export class IsExtendedCurrencyConstraint
|
||||
implements ValidatorConstraintInterface
|
||||
{
|
||||
public defaultMessage(args: ValidationArguments) {
|
||||
return '$value must be a valid ISO4217 currency code';
|
||||
}
|
||||
|
||||
public validate(currency: any) {
|
||||
// Return true if currency is a standard ISO 4217 code or a derived currency
|
||||
return (
|
||||
isISO4217CurrencyCode(currency) ||
|
||||
[
|
||||
...DERIVED_CURRENCIES.map((derivedCurrency) => {
|
||||
return derivedCurrency.currency;
|
||||
}),
|
||||
'USX'
|
||||
].includes(currency)
|
||||
);
|
||||
}
|
||||
}
|
@ -138,7 +138,7 @@
|
||||
<li>
|
||||
<a
|
||||
class="align-items-baseline d-flex"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
target="_blank"
|
||||
title="Follow Ghostfolio on X (formerly Twitter)"
|
||||
>X (formerly Twitter)<ion-icon class="ml-1" name="open-outline"
|
||||
|
@ -10,6 +10,7 @@ import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
|
||||
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
|
||||
import { SelectionModel } from '@angular/cdk/collections';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
@ -68,6 +69,11 @@ export class AdminMarketDataComponent
|
||||
};
|
||||
})
|
||||
.concat([
|
||||
{
|
||||
id: 'BENCHMARKS',
|
||||
label: $localize`Benchmarks`,
|
||||
type: <Filter['type']>'PRESET_ID'
|
||||
},
|
||||
{
|
||||
id: 'CURRENCIES',
|
||||
label: $localize`Currencies`,
|
||||
@ -92,6 +98,7 @@ export class AdminMarketDataComponent
|
||||
public defaultDateFormat: string;
|
||||
public deviceType: string;
|
||||
public displayedColumns = [
|
||||
'select',
|
||||
'nameWithSymbol',
|
||||
'dataSource',
|
||||
'assetClass',
|
||||
@ -110,13 +117,14 @@ export class AdminMarketDataComponent
|
||||
public isUUID = isUUID;
|
||||
public placeholder = '';
|
||||
public pageSize = DEFAULT_PAGE_SIZE;
|
||||
public selection: SelectionModel<Partial<SymbolProfile>>;
|
||||
public totalItems = 0;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminMarketDataService: AdminMarketDataService,
|
||||
public adminMarketDataService: AdminMarketDataService,
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
@ -183,6 +191,8 @@ export class AdminMarketDataComponent
|
||||
|
||||
this.benchmarks = benchmarks;
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.selection = new SelectionModel(true);
|
||||
}
|
||||
|
||||
public onChangePage(page: PageEvent) {
|
||||
@ -193,8 +203,16 @@ export class AdminMarketDataComponent
|
||||
});
|
||||
}
|
||||
|
||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
this.adminMarketDataService.deleteProfileData({ dataSource, symbol });
|
||||
public onDeleteAssetProfile({ dataSource, symbol }: UniqueAsset) {
|
||||
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
|
||||
}
|
||||
|
||||
public onDeleteAssetProfiles() {
|
||||
this.adminMarketDataService.deleteAssetProfiles(
|
||||
this.selection.selected.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public onGather7Days() {
|
||||
@ -281,6 +299,8 @@ export class AdminMarketDataComponent
|
||||
this.placeholder =
|
||||
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
|
||||
|
||||
this.selection.clear();
|
||||
|
||||
this.adminService
|
||||
.fetchAdminMarketData({
|
||||
sortColumn,
|
||||
|
@ -11,208 +11,240 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
mat-table
|
||||
matSort
|
||||
matSortActive="symbol"
|
||||
matSortDirection="asc"
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Symbol</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.symbol }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="nameWithSymbol">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header="symbol"
|
||||
>
|
||||
<ng-container i18n>Name</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
|
||||
<div class="text-truncate">{{ element.name }}</div>
|
||||
@if (!isUUID(element.symbol)) {
|
||||
<div>
|
||||
<small class="text-muted">{{
|
||||
element.symbol | gfSymbol
|
||||
}}</small>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="dataSource">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Data Source</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.dataSource }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="assetClass">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Asset Class</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.assetClass }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="assetSubClass">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Asset Sub Class</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.assetSubClass }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="date">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<ng-container i18n>First Activity</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ (element.date | date: defaultDateFormat) ?? '' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="activitiesCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Activities Count</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
{{ element.activitiesCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="marketDataItemCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<ng-container i18n>Historical Data</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
{{ element.marketDataItemCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="sectorsCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<ng-container i18n>Sectors Count</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
{{ element.sectorsCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="countriesCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<ng-container i18n>Countries Count</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
{{ element.countriesCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="comment">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@if (element.comment) {
|
||||
<ion-icon class="d-block" name="document-text-outline" />
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions" stickyEnd>
|
||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="assetProfilesActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical" />
|
||||
</button>
|
||||
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onGather7Days()">
|
||||
<ng-container i18n>Gather Recent Data</ng-container>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onGatherMax()">
|
||||
<ng-container i18n>Gather All Data</ng-container>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onGatherProfileData()">
|
||||
<ng-container i18n>Gather Profile Data</ng-container>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal" />
|
||||
</button>
|
||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||
<a
|
||||
mat-menu-item
|
||||
[queryParams]="{
|
||||
assetProfileDialog: true,
|
||||
dataSource: element.dataSource,
|
||||
<div class="overflow-x-auto">
|
||||
<table
|
||||
class="gf-table w-100"
|
||||
mat-table
|
||||
matSort
|
||||
matSortActive="symbol"
|
||||
matSortDirection="asc"
|
||||
[dataSource]="dataSource"
|
||||
>
|
||||
<ng-container matColumnDef="select">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@if (
|
||||
adminMarketDataService.hasPermissionToDeleteAssetProfile({
|
||||
activitiesCount: element.activitiesCount,
|
||||
isBenchmark: element.isBenchmark,
|
||||
symbol: element.symbol
|
||||
}"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="create-outline" />
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</a>
|
||||
})
|
||||
) {
|
||||
<mat-checkbox
|
||||
color="primary"
|
||||
[checked]="selection.isSelected(element)"
|
||||
(change)="$event ? selection.toggle(element) : null"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
</mat-checkbox>
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="symbol">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Symbol</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.symbol }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="nameWithSymbol">
|
||||
<th
|
||||
*matHeaderCellDef
|
||||
class="px-1"
|
||||
mat-header-cell
|
||||
mat-sort-header="symbol"
|
||||
>
|
||||
<ng-container i18n>Name</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="line-height-1 px-1" mat-cell>
|
||||
<div class="text-truncate">{{ element.name }}</div>
|
||||
@if (!isUUID(element.symbol)) {
|
||||
<div>
|
||||
<small class="text-muted">{{
|
||||
element.symbol | gfSymbol
|
||||
}}</small>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="dataSource">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Data Source</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.dataSource }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="assetClass">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Asset Class</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.assetClass }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="assetSubClass">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Asset Sub Class</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ element.assetSubClass }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="date">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<ng-container i18n>First Activity</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
{{ (element.date | date: defaultDateFormat) ?? '' }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="activitiesCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
|
||||
<ng-container i18n>Activities Count</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
{{ element.activitiesCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="marketDataItemCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<ng-container i18n>Historical Data</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
{{ element.marketDataItemCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="sectorsCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<ng-container i18n>Sectors Count</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
{{ element.sectorsCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="countriesCount">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell>
|
||||
<ng-container i18n>Countries Count</ng-container>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
{{ element.countriesCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="comment">
|
||||
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
@if (element.comment) {
|
||||
<ion-icon class="d-block" name="document-text-outline" />
|
||||
}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions" stickyEnd>
|
||||
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="
|
||||
element.activitiesCount !== 0 ||
|
||||
element.isBenchmark ||
|
||||
element.symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
||||
"
|
||||
(click)="
|
||||
onDeleteProfileData({
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="assetProfilesActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-vertical" />
|
||||
</button>
|
||||
<mat-menu #assetProfilesActionsMenu="matMenu" xPosition="before">
|
||||
<button mat-menu-item (click)="onGather7Days()">
|
||||
<ng-container i18n>Gather Recent Data</ng-container>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onGatherMax()">
|
||||
<ng-container i18n>Gather All Data</ng-container>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onGatherProfileData()">
|
||||
<ng-container i18n>Gather Profile Data</ng-container>
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="!selection.hasValue()"
|
||||
(click)="onDeleteAssetProfiles()"
|
||||
>
|
||||
<ng-container i18n>Delete Asset Profiles</ng-container>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
[matMenuTriggerFor]="assetProfileActionsMenu"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal" />
|
||||
</button>
|
||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||
<a
|
||||
mat-menu-item
|
||||
[queryParams]="{
|
||||
assetProfileDialog: true,
|
||||
dataSource: element.dataSource,
|
||||
symbol: element.symbol
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
}"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="create-outline" />
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="
|
||||
!adminMarketDataService.hasPermissionToDeleteAssetProfile({
|
||||
activitiesCount: element.activitiesCount,
|
||||
isBenchmark: element.isBenchmark,
|
||||
symbol: element.symbol
|
||||
})
|
||||
"
|
||||
(click)="
|
||||
onDeleteAssetProfile({
|
||||
dataSource: element.dataSource,
|
||||
symbol: element.symbol
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete</span>
|
||||
</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
<tr
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="cursor-pointer"
|
||||
mat-row
|
||||
(click)="
|
||||
onOpenAssetProfileDialog({
|
||||
dataSource: row.dataSource,
|
||||
symbol: row.symbol
|
||||
})
|
||||
"
|
||||
></tr>
|
||||
</table>
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
<tr
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="cursor-pointer"
|
||||
mat-row
|
||||
(click)="
|
||||
onOpenAssetProfileDialog({
|
||||
dataSource: row.dataSource,
|
||||
symbol: row.symbol
|
||||
})
|
||||
"
|
||||
></tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems"
|
||||
|
@ -4,6 +4,7 @@ import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatPaginatorModule } from '@angular/material/paginator';
|
||||
import { MatSortModule } from '@angular/material/sort';
|
||||
@ -25,6 +26,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
|
||||
GfCreateAssetProfileDialogModule,
|
||||
GfSymbolModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatMenuModule,
|
||||
MatPaginatorModule,
|
||||
MatSortModule,
|
||||
|
@ -1,14 +1,19 @@
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { ghostfolioScraperApiSymbolPrefix } from '@ghostfolio/common/config';
|
||||
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
AdminMarketDataItem,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@angular/core';
|
||||
import { takeUntil } from 'rxjs';
|
||||
import { EMPTY, catchError, finalize, forkJoin, takeUntil } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class AdminMarketDataService {
|
||||
public constructor(private adminService: AdminService) {}
|
||||
|
||||
public deleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
public deleteAssetProfile({ dataSource, symbol }: UniqueAsset) {
|
||||
const confirmation = confirm(
|
||||
$localize`Do you really want to delete this asset profile?`
|
||||
);
|
||||
@ -23,4 +28,44 @@ export class AdminMarketDataService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public deleteAssetProfiles(uniqueAssets: UniqueAsset[]) {
|
||||
const confirmation = confirm(
|
||||
$localize`Do you really want to delete these asset profiles?`
|
||||
);
|
||||
|
||||
if (confirmation) {
|
||||
const deleteRequests = uniqueAssets.map(({ dataSource, symbol }) => {
|
||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||
});
|
||||
|
||||
forkJoin(deleteRequests)
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
alert($localize`Oops! Could not delete asset profiles.`);
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
finalize(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
})
|
||||
)
|
||||
.subscribe(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
public hasPermissionToDeleteAssetProfile({
|
||||
activitiesCount,
|
||||
isBenchmark,
|
||||
symbol
|
||||
}: Pick<AdminMarketDataItem, 'activitiesCount' | 'isBenchmark' | 'symbol'>) {
|
||||
return (
|
||||
activitiesCount === 0 &&
|
||||
!isBenchmark &&
|
||||
!isCurrency(getCurrencyFromSymbol(symbol)) &&
|
||||
!symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -87,7 +87,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminMarketDataService: AdminMarketDataService,
|
||||
public adminMarketDataService: AdminMarketDataService,
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
|
||||
@ -176,7 +176,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
|
||||
this.adminMarketDataService.deleteProfileData({ dataSource, symbol });
|
||||
this.adminMarketDataService.deleteAssetProfile({ dataSource, symbol });
|
||||
|
||||
this.dialogRef.close();
|
||||
}
|
||||
|
@ -48,9 +48,11 @@
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="
|
||||
assetProfile?.activitiesCount !== 0 ||
|
||||
isBenchmark ||
|
||||
data.symbol.startsWith(ghostfolioScraperApiSymbolPrefix)
|
||||
!adminMarketDataService.hasPermissionToDeleteAssetProfile({
|
||||
activitiesCount: assetProfile?.activitiesCount,
|
||||
isBenchmark: isBenchmark,
|
||||
symbol: data.symbol
|
||||
})
|
||||
"
|
||||
(click)="
|
||||
onDeleteProfileData({
|
||||
|
@ -55,7 +55,7 @@
|
||||
>
|
||||
community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
@ -75,7 +75,7 @@
|
||||
<p class="align-items-center d-flex justify-content-center">
|
||||
<a
|
||||
class="mx-2"
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
mat-icon-button
|
||||
title="Follow Ghostfolio on X (formerly Twitter)"
|
||||
>
|
||||
|
@ -131,9 +131,9 @@
|
||||
</p>
|
||||
<p>
|
||||
Du erreichst mich per E-Mail unter
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> oder auf
|
||||
Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> oder auf X
|
||||
(ehemals Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
Ich freue mich, von dir zu hören.<br />
|
||||
|
@ -126,8 +126,9 @@
|
||||
</p>
|
||||
<p>
|
||||
You can reach me by e-mail at
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on X
|
||||
(formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
I look forward to hearing from you.<br />
|
||||
|
@ -39,7 +39,7 @@
|
||||
</p>
|
||||
<p>
|
||||
At the end of 2021, Ghostfolio reached an important milestone:
|
||||
<a href="https://twitter.com/ghostfolio_/status/1470075774640218121"
|
||||
<a href="https://x.com/ghostfolio_/status/1470075774640218121"
|
||||
>100 stars</a
|
||||
>
|
||||
on GitHub. This is really exciting with almost no marketing. I am a
|
||||
@ -100,9 +100,10 @@
|
||||
of users. In the future, I would like to involve more contributors
|
||||
to further extend the functionality of Ghostfolio (e.g. with new
|
||||
reports). Get in touch with me by e-mail at
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> if
|
||||
you are interested, I’m happy to discuss ideas.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on X
|
||||
(formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a> if you are
|
||||
interested, I’m happy to discuss ideas.
|
||||
</p>
|
||||
<p>
|
||||
I would like to say thank you for all your feedback and support
|
||||
|
@ -90,8 +90,9 @@
|
||||
<p>
|
||||
If you would like to provide feedback or get involved in further
|
||||
development of Ghostfolio, please get in touch by e-mail via
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on X
|
||||
(formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
I look forward to hearing from you.<br />
|
||||
|
@ -34,9 +34,9 @@
|
||||
>Slack</a
|
||||
>
|
||||
as well as 100 followers on
|
||||
<a href="https://twitter.com/ghostfolio_">Twitter</a>. If you have
|
||||
not joined yet, this is a good time to make sure you do not miss out
|
||||
on any future updates.
|
||||
<a href="https://x.com/ghostfolio_">Twitter</a>. If you have not
|
||||
joined yet, this is a good time to make sure you do not miss out on
|
||||
any future updates.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
@ -91,9 +91,10 @@
|
||||
engineering to realize the full potential of open source software.
|
||||
If you are a web developer and interested in personal finance,
|
||||
please get in touch by e-mail via
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>. We
|
||||
are happy to discuss ideas.
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on X
|
||||
(formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>. We are
|
||||
happy to discuss ideas.
|
||||
</p>
|
||||
<p>
|
||||
We would like to say thank you for all your feedback and support
|
||||
|
@ -84,8 +84,8 @@
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
>Slack</a
|
||||
>
|
||||
community or get in touch on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
|
||||
community or get in touch on X (formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a> or by
|
||||
e-mail via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
||||
</p>
|
||||
<p>
|
||||
|
@ -90,8 +90,8 @@
|
||||
target="_blank"
|
||||
>Slack</a
|
||||
>
|
||||
community or via Twitter
|
||||
<a href="https://twitter.com/ghostfolio_" target="_blank"
|
||||
community or via X (formerly Twitter)
|
||||
<a href="https://x.com/ghostfolio_" target="_blank"
|
||||
>@ghostfolio_</a
|
||||
>. We look forward to hearing from you!
|
||||
</p>
|
||||
|
@ -122,8 +122,9 @@
|
||||
>Slack</a
|
||||
>
|
||||
community or connect with
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> on
|
||||
Twitter. We are happy to discuss ideas and get you involved.
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a> on X
|
||||
(formerly Twitter). We are happy to discuss ideas and get you
|
||||
involved.
|
||||
</p>
|
||||
<p>Thank you for all your feedback and support.</p>
|
||||
<p>
|
||||
|
@ -25,7 +25,7 @@
|
||||
<p>
|
||||
OSS Friends started as a simple
|
||||
<a
|
||||
href="https://twitter.com/formbricks/status/1660735970281508878"
|
||||
href="https://x.com/formbricks/status/1660735970281508878"
|
||||
target="_blank"
|
||||
>post</a
|
||||
>
|
||||
|
@ -123,7 +123,7 @@
|
||||
</li>
|
||||
<li>
|
||||
On
|
||||
<a href="https://twitter.com/ghostfolio_" target="_blank">X</a>
|
||||
<a href="https://x.com/ghostfolio_" target="_blank">X</a>
|
||||
(formerly Twitter), over
|
||||
<strong>300 investors and personal finance enthusiasts</strong>
|
||||
follow Ghostfolio, keen to stay updated on the latest
|
||||
@ -151,7 +151,7 @@
|
||||
<p>
|
||||
<strong>Follow us on X</strong>: For release updates and market
|
||||
insights, follow
|
||||
<a href="https://twitter.com/ghostfolio_" target="_blank"
|
||||
<a href="https://x.com/ghostfolio_" target="_blank"
|
||||
>Ghostfolio on X</a
|
||||
>. It is the perfect place to stay informed and connect with our
|
||||
team.
|
||||
|
@ -89,7 +89,7 @@
|
||||
>Slack</a
|
||||
>
|
||||
community or get in touch on X
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a>.
|
||||
<a href="https://x.com/ghostfolio_">@ghostfolio_</a>.
|
||||
</p>
|
||||
<p>
|
||||
We look forward to hearing from you.<br />
|
||||
|
@ -122,7 +122,7 @@
|
||||
>
|
||||
community,
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
@ -152,7 +152,7 @@
|
||||
>Slack </a
|
||||
>community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
|
@ -151,7 +151,7 @@
|
||||
>Slack </a
|
||||
>community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
|
@ -86,6 +86,17 @@
|
||||
</ol>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title>What is the concept of platforms?</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
Platforms are used to group multiple accounts, such as a savings
|
||||
account and a trading account at the same bank. By assigning accounts
|
||||
to the same platform, they are displayed with a unified icon and you
|
||||
gain insights into platform-specific risks.
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title>How do I add a new platform?</mat-card-title>
|
||||
@ -186,7 +197,7 @@
|
||||
>Slack </a
|
||||
>community, post to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
href="https://x.com/ghostfolio_"
|
||||
title="Post to Ghostfolio on X (formerly Twitter)"
|
||||
>@ghostfolio_</a
|
||||
>
|
||||
|
@ -145,8 +145,9 @@
|
||||
</h4>
|
||||
<p class="m-0">
|
||||
Check the rate of return of your portfolio for
|
||||
<code>Today</code>, <code>YTD</code>, <code>1Y</code>,
|
||||
<code>5Y</code>, and <code>Max</code>.
|
||||
<code>Today</code>, <code>WTD</code>, <code>MTD</code>,
|
||||
<code>YTD</code>, <code>1Y</code>, <code>5Y</code>, and
|
||||
<code>Max</code>.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
|
@ -454,30 +454,22 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
if (position.holdings.length > 0) {
|
||||
for (const holding of position.holdings) {
|
||||
const { name, valueInBaseCurrency } = holding;
|
||||
const { allocationInPercentage, name, valueInBaseCurrency } =
|
||||
holding;
|
||||
|
||||
if (
|
||||
!this.hasImpersonationId &&
|
||||
!this.user.settings.isRestrictedView
|
||||
) {
|
||||
if (this.topHoldingsMap[name]?.value) {
|
||||
this.topHoldingsMap[name].value +=
|
||||
valueInBaseCurrency *
|
||||
(isNumber(position.valueInBaseCurrency)
|
||||
? position.valueInBaseCurrency
|
||||
: position.valueInPercentage);
|
||||
} else {
|
||||
this.topHoldingsMap[name] = {
|
||||
name,
|
||||
value:
|
||||
valueInBaseCurrency *
|
||||
(isNumber(position.valueInBaseCurrency)
|
||||
? this.portfolioDetails.holdings[symbol]
|
||||
.valueInBaseCurrency
|
||||
: this.portfolioDetails.holdings[symbol]
|
||||
.valueInPercentage)
|
||||
};
|
||||
}
|
||||
if (this.topHoldingsMap[name]?.value) {
|
||||
this.topHoldingsMap[name].value += isNumber(valueInBaseCurrency)
|
||||
? valueInBaseCurrency
|
||||
: allocationInPercentage *
|
||||
this.portfolioDetails.holdings[symbol].valueInPercentage;
|
||||
} else {
|
||||
this.topHoldingsMap[name] = {
|
||||
name,
|
||||
value: isNumber(valueInBaseCurrency)
|
||||
? valueInBaseCurrency
|
||||
: allocationInPercentage *
|
||||
this.portfolioDetails.holdings[symbol].valueInPercentage
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -562,6 +554,14 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.topHoldings = Object.values(this.topHoldingsMap)
|
||||
.map(({ name, value }) => {
|
||||
if (this.hasImpersonationId || this.user.settings.isRestrictedView) {
|
||||
return {
|
||||
name,
|
||||
allocationInPercentage: value,
|
||||
valueInBaseCurrency: null
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
allocationInPercentage:
|
||||
@ -570,7 +570,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
return b.valueInBaseCurrency - a.valueInBaseCurrency;
|
||||
return b.allocationInPercentage - a.allocationInPercentage;
|
||||
});
|
||||
|
||||
if (this.topHoldings.length > MAX_TOP_HOLDINGS) {
|
||||
|
@ -55,6 +55,22 @@
|
||||
</div>
|
||||
<h2 class="h4 mb-3" i18n>Markets</h2>
|
||||
<div class="mb-5">
|
||||
<div class="mb-4 media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Crypto Coins Heatmap</h3>
|
||||
<div class="mb-1">
|
||||
With the <i>Crypto Coins Heatmap</i> you can track the daily
|
||||
market movements of cryptocurrencies as a visual snapshot.
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.tradingview.com/heatmap/crypto"
|
||||
target="_blank"
|
||||
>Crypto Coins Heatmap →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4 media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Fear & Greed Index</h3>
|
||||
@ -73,10 +89,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="media">
|
||||
<div class="media-body">
|
||||
<div class="mb-4 media">
|
||||
<h3 class="h5 mt-0">Inflation Chart</h3>
|
||||
<div class="mb-1">
|
||||
Inflation Chart helps you find the intrinsic value of stock
|
||||
<i>Inflation Chart</i> 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).
|
||||
@ -88,6 +104,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Stock Heatmap</h3>
|
||||
<div class="mb-1">
|
||||
With the <i>Stock Heatmap</i> you can track the daily market
|
||||
movements of stocks as a visual snapshot.
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="https://www.tradingview.com/heatmap/stock"
|
||||
target="_blank"
|
||||
>Stock Heatmap →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="h4 mb-3" i18n>Glossary</h2>
|
||||
<div>
|
||||
|
@ -214,16 +214,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-menu-panel {
|
||||
&.assistant {
|
||||
max-width: unset !important;
|
||||
|
||||
.mat-mdc-menu-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-dark-theme {
|
||||
background: var(--dark-background);
|
||||
color: rgba(var(--light-primary-text));
|
||||
@ -366,6 +356,10 @@ ngx-skeleton-loader {
|
||||
}
|
||||
|
||||
.cdk-overlay-container {
|
||||
.cdk-global-overlay-wrapper {
|
||||
justify-content: center !important;
|
||||
}
|
||||
|
||||
.cdk-overlay-pane {
|
||||
max-width: 95vw !important;
|
||||
}
|
||||
@ -453,6 +447,14 @@ ngx-skeleton-loader {
|
||||
}
|
||||
|
||||
.mat-mdc-menu-panel {
|
||||
&.assistant {
|
||||
max-width: unset !important;
|
||||
|
||||
.mat-mdc-menu-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mat-mdc-menu-item {
|
||||
&.font-weight-bold {
|
||||
.mat-mdc-menu-item-text {
|
||||
|
@ -1,4 +1,3 @@
|
||||
version: '3.9'
|
||||
services:
|
||||
ghostfolio:
|
||||
build: ../
|
||||
|
@ -1,4 +1,3 @@
|
||||
version: '3.9'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
|
@ -1,4 +1,3 @@
|
||||
version: '3.9'
|
||||
services:
|
||||
ghostfolio:
|
||||
image: ghostfolio/ghostfolio:latest
|
||||
|
@ -18,6 +18,13 @@ export const personalFinanceTools: Product[] = [
|
||||
origin: `United States`,
|
||||
slogan: 'Investment Software Suite'
|
||||
},
|
||||
{
|
||||
founded: 2016,
|
||||
key: 'alphatrackr',
|
||||
languages: ['English'],
|
||||
name: 'AlphaTrackr',
|
||||
slogan: 'Investment Portfolio Tracking Tool'
|
||||
},
|
||||
{
|
||||
founded: 2017,
|
||||
hasSelfHostingAbility: false,
|
||||
@ -26,6 +33,17 @@ export const personalFinanceTools: Product[] = [
|
||||
origin: `Switzerland`,
|
||||
slogan: 'Simplicity for Complex Wealth'
|
||||
},
|
||||
{
|
||||
founded: 2018,
|
||||
hasFreePlan: true,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'anlage.app',
|
||||
languages: ['English'],
|
||||
name: 'Anlage.App',
|
||||
origin: `Austria`,
|
||||
pricingPerYear: '$120',
|
||||
slogan: 'Analyze and track your portfolio.'
|
||||
},
|
||||
{
|
||||
founded: 2022,
|
||||
hasFreePlan: true,
|
||||
@ -190,6 +208,16 @@ export const personalFinanceTools: Product[] = [
|
||||
origin: `Germany`,
|
||||
slogan: 'Volle Kontrolle über deine Investitionen'
|
||||
},
|
||||
{
|
||||
hasFreePlan: true,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'holistic-capital',
|
||||
languages: ['Deutsch'],
|
||||
name: 'Holistic',
|
||||
origin: `Germany`,
|
||||
slogan: 'Die All-in-One Lösung für dein Vermögen.',
|
||||
useAnonymously: true
|
||||
},
|
||||
{
|
||||
hasFreePlan: true,
|
||||
hasSelfHostingAbility: false,
|
||||
@ -264,6 +292,17 @@ export const personalFinanceTools: Product[] = [
|
||||
region: `United States`,
|
||||
slogan: 'Your financial future, in your control'
|
||||
},
|
||||
{
|
||||
hasFreePlan: false,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'merlincrypto',
|
||||
languages: ['English'],
|
||||
name: 'Merlin',
|
||||
origin: `United States`,
|
||||
pricingPerYear: '$204',
|
||||
region: 'Canada, United States',
|
||||
slogan: 'The smartest way to track your crypto'
|
||||
},
|
||||
{
|
||||
founded: 2019,
|
||||
hasFreePlan: false,
|
||||
@ -331,6 +370,14 @@ export const personalFinanceTools: Product[] = [
|
||||
pricingPerYear: '$360',
|
||||
slogan: 'Tools for Better Investors'
|
||||
},
|
||||
{
|
||||
hasFreePlan: true,
|
||||
key: 'portfoloo',
|
||||
name: 'Portfoloo',
|
||||
note: 'Portfoloo has discontinued',
|
||||
slogan:
|
||||
'Free Stock Portfolio Tracker with unlimited portfolio and stocks for DIY investors'
|
||||
},
|
||||
{
|
||||
founded: 2021,
|
||||
hasFreePlan: true,
|
||||
@ -370,6 +417,13 @@ export const personalFinanceTools: Product[] = [
|
||||
pricingPerYear: '$239',
|
||||
slogan: 'Stock Market Analysis & Tools for Investors'
|
||||
},
|
||||
{
|
||||
founded: 2022,
|
||||
key: 'segmio',
|
||||
name: 'Segmio',
|
||||
origin: `Romania`,
|
||||
slogan: 'Wealth Management and Net Worth Tracking'
|
||||
},
|
||||
{
|
||||
founded: 2007,
|
||||
hasFreePlan: true,
|
||||
@ -381,6 +435,13 @@ export const personalFinanceTools: Product[] = [
|
||||
region: `Global`,
|
||||
slogan: 'Stock Portfolio Tracker'
|
||||
},
|
||||
{
|
||||
hasFreePlan: true,
|
||||
key: 'sharesmaster',
|
||||
name: 'SharesMaster',
|
||||
note: 'SharesMaster has discontinued',
|
||||
slogan: 'Free Stock Portfolio Tracker'
|
||||
},
|
||||
{
|
||||
hasFreePlan: true,
|
||||
hasSelfHostingAbility: false,
|
||||
@ -498,6 +559,15 @@ export const personalFinanceTools: Product[] = [
|
||||
pricingPerYear: '$50',
|
||||
slogan: 'See all your investments in one place'
|
||||
},
|
||||
{
|
||||
founded: 2018,
|
||||
hasSelfHostingAbility: false,
|
||||
key: 'wealthy-tracker',
|
||||
languages: ['English'],
|
||||
name: 'Wealthy Tracker',
|
||||
origin: `India`,
|
||||
slogan: 'One app to manage all your investments'
|
||||
},
|
||||
{
|
||||
key: 'whal',
|
||||
name: 'Whal',
|
||||
|
@ -1,4 +1,5 @@
|
||||
export type MarketDataPreset =
|
||||
| 'BENCHMARKS'
|
||||
| 'CURRENCIES'
|
||||
| 'ETF_WITHOUT_COUNTRIES'
|
||||
| 'ETF_WITHOUT_SECTORS';
|
||||
|
66
package.json
66
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "2.90.0",
|
||||
"version": "2.93.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"repository": "https://github.com/ghostfolio/ghostfolio",
|
||||
@ -54,17 +54,17 @@
|
||||
"workspace-generator": "nx workspace-generator"
|
||||
},
|
||||
"dependencies": {
|
||||
"@angular/animations": "18.0.2",
|
||||
"@angular/cdk": "18.0.2",
|
||||
"@angular/common": "18.0.2",
|
||||
"@angular/compiler": "18.0.2",
|
||||
"@angular/core": "18.0.2",
|
||||
"@angular/forms": "18.0.2",
|
||||
"@angular/material": "18.0.2",
|
||||
"@angular/platform-browser": "18.0.2",
|
||||
"@angular/platform-browser-dynamic": "18.0.2",
|
||||
"@angular/router": "18.0.2",
|
||||
"@angular/service-worker": "18.0.2",
|
||||
"@angular/animations": "18.0.4",
|
||||
"@angular/cdk": "18.0.4",
|
||||
"@angular/common": "18.0.4",
|
||||
"@angular/compiler": "18.0.4",
|
||||
"@angular/core": "18.0.4",
|
||||
"@angular/forms": "18.0.4",
|
||||
"@angular/material": "18.0.4",
|
||||
"@angular/platform-browser": "18.0.4",
|
||||
"@angular/platform-browser-dynamic": "18.0.4",
|
||||
"@angular/router": "18.0.4",
|
||||
"@angular/service-worker": "18.0.4",
|
||||
"@codewithdan/observable-store": "2.2.15",
|
||||
"@dfinity/agent": "0.15.7",
|
||||
"@dfinity/auth-client": "0.15.7",
|
||||
@ -84,7 +84,7 @@
|
||||
"@nestjs/platform-express": "10.1.3",
|
||||
"@nestjs/schedule": "3.0.2",
|
||||
"@nestjs/serve-static": "4.0.0",
|
||||
"@prisma/client": "5.15.0",
|
||||
"@prisma/client": "5.16.1",
|
||||
"@simplewebauthn/browser": "9.0.1",
|
||||
"@simplewebauthn/server": "9.0.3",
|
||||
"@stripe/stripe-js": "3.5.0",
|
||||
@ -126,7 +126,7 @@
|
||||
"passport": "0.7.0",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.1",
|
||||
"prisma": "5.15.0",
|
||||
"prisma": "5.16.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"rxjs": "7.5.6",
|
||||
"stripe": "15.11.0",
|
||||
@ -137,29 +137,29 @@
|
||||
"zone.js": "0.14.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "18.0.3",
|
||||
"@angular-devkit/core": "18.0.3",
|
||||
"@angular-devkit/schematics": "18.0.3",
|
||||
"@angular-devkit/build-angular": "18.0.5",
|
||||
"@angular-devkit/core": "18.0.5",
|
||||
"@angular-devkit/schematics": "18.0.5",
|
||||
"@angular-eslint/eslint-plugin": "18.0.1",
|
||||
"@angular-eslint/eslint-plugin-template": "18.0.1",
|
||||
"@angular-eslint/template-parser": "18.0.1",
|
||||
"@angular/cli": "18.0.3",
|
||||
"@angular/compiler-cli": "18.0.2",
|
||||
"@angular/language-service": "18.0.2",
|
||||
"@angular/localize": "18.0.2",
|
||||
"@angular/pwa": "18.0.3",
|
||||
"@angular/cli": "18.0.5",
|
||||
"@angular/compiler-cli": "18.0.4",
|
||||
"@angular/language-service": "18.0.4",
|
||||
"@angular/localize": "18.0.4",
|
||||
"@angular/pwa": "18.0.5",
|
||||
"@nestjs/schematics": "10.0.1",
|
||||
"@nestjs/testing": "10.1.3",
|
||||
"@nx/angular": "19.2.2",
|
||||
"@nx/cypress": "19.2.2",
|
||||
"@nx/eslint-plugin": "19.2.2",
|
||||
"@nx/jest": "19.2.2",
|
||||
"@nx/js": "19.2.2",
|
||||
"@nx/nest": "19.2.2",
|
||||
"@nx/node": "19.2.2",
|
||||
"@nx/storybook": "19.2.2",
|
||||
"@nx/web": "19.2.2",
|
||||
"@nx/workspace": "19.2.2",
|
||||
"@nx/angular": "19.4.0",
|
||||
"@nx/cypress": "19.4.0",
|
||||
"@nx/eslint-plugin": "19.4.0",
|
||||
"@nx/jest": "19.4.0",
|
||||
"@nx/js": "19.4.0",
|
||||
"@nx/nest": "19.4.0",
|
||||
"@nx/node": "19.4.0",
|
||||
"@nx/storybook": "19.4.0",
|
||||
"@nx/web": "19.4.0",
|
||||
"@nx/workspace": "19.4.0",
|
||||
"@schematics/angular": "18.0.3",
|
||||
"@simplewebauthn/types": "9.0.1",
|
||||
"@storybook/addon-essentials": "7.6.5",
|
||||
@ -188,7 +188,7 @@
|
||||
"jest": "29.4.3",
|
||||
"jest-environment-jsdom": "29.4.3",
|
||||
"jest-preset-angular": "14.1.0",
|
||||
"nx": "19.2.2",
|
||||
"nx": "19.4.0",
|
||||
"prettier": "3.3.1",
|
||||
"prettier-plugin-organize-attributes": "1.0.0",
|
||||
"react": "18.2.0",
|
||||
|
30
test/import/ok-derived-currency.json
Normal file
30
test/import/ok-derived-currency.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"meta": {
|
||||
"date": "2024-06-28T00:00:00.000Z",
|
||||
"version": "dev"
|
||||
},
|
||||
"accounts": [
|
||||
{
|
||||
"balance": 2000,
|
||||
"currency": "USD",
|
||||
"id": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
|
||||
"isExcluded": false,
|
||||
"name": "My Online Trading Account",
|
||||
"platformId": null
|
||||
}
|
||||
],
|
||||
"activities": [
|
||||
{
|
||||
"accountId": "b2d3fe1d-d6a8-41a3-be39-07ef5e9480f0",
|
||||
"comment": null,
|
||||
"fee": 0,
|
||||
"quantity": 5,
|
||||
"type": "BUY",
|
||||
"unitPrice": 10875.00,
|
||||
"currency": "ZAc",
|
||||
"dataSource": "YAHOO",
|
||||
"date": "2024-06-27T22:00:00.000Z",
|
||||
"symbol": "JSE.JO"
|
||||
}
|
||||
]
|
||||
}
|
Reference in New Issue
Block a user