Compare commits

..

32 Commits

Author SHA1 Message Date
830569b38e Release 2.93.0 (#3551) 2024-07-07 18:25:33 +02:00
35b4aef06f Feature/improve market state logic for forex in eod historical data service (#3550) 2024-07-07 18:23:51 +02:00
bc2fd9c970 Feature/add WTD and MTD to documentation (#3542) 2024-07-07 09:55:52 +02:00
c42a8aebed Feature/add platforms concept to faq page (#3549)
* Add concept of platforms

* Update changelog
2024-07-07 09:55:12 +02:00
fad1adb91b Feature/improve usability to delete currency asset profile (#3541)
* Improve usability

* Update changelog
2024-07-07 09:54:54 +02:00
9cd37f8de0 Feature/add crypto coins and stock heatmaps to resources page (#3548)
* Add heatmaps

* Crypto Coins Heatmap
* Stock Heatmap

* Update changelog
2024-07-07 09:40:55 +02:00
d49b90d7a5 Feature/refresh cryptocurrencies list 20240706 (#3544)
* Refresh cryptocurrencies list

* Update changelog
2024-07-07 09:39:29 +02:00
130a9ea062 Feature/remove obsolete version from docker compose files (#3543)
* Remove obsolete version

* Update changelog
2024-07-07 09:16:48 +02:00
ffc6309850 Feature/refactor thresholds of x ray rules (#3545)
* Refactor thresholds

* Update changelog
2024-07-07 08:25:51 +02:00
976cc7f243 Feature/upgrade nx to version 19.4.0 (#3540)
* Upgrade Nx to version 19.4.0

* Update changelog
2024-07-06 22:15:33 +02:00
7067aca04b Feature/replace twitter.com with x.com (#3535)
* Replace twitter.com with x.com
2024-07-05 17:26:12 +02:00
1c9805bb96 Feature/improve allocations by etf holding for impersonation mode (#3534)
* Improve allocations by ETF holding for impersonation mode

* Update changelog
2024-07-04 20:25:15 +02:00
8227a2d91a Feature/improve detection of json used via scraper configuration (#3539)
* Improve detection of json

* Update changelog
2024-07-03 18:16:07 +02:00
194aee97db Feature/update development instructions to control flow (#3466) 2024-07-02 11:58:13 +02:00
0f77169952 Fix wording (#3463) 2024-07-01 21:03:15 +02:00
0f8dc62c53 Release 2.92.0 (#3532) 2024-06-30 09:23:03 +02:00
554136cdcd Feature/bulk deletion for asset profiles (#3531)
* Add support for bulk deletion of asset profiles

* Update changelog
2024-06-30 09:21:04 +02:00
83b5cfff1f Feature/upgrade prisma to version 5.16.1 (#3526)
* Upgrade prisma to version 5.16.1

* Update changelog
2024-06-29 17:06:21 +02:00
dcec3accf0 Feature/improve caching of benchmarks (#3530)
* Improve caching

* Update changelog
2024-06-29 16:53:35 +02:00
f08b0b570b Feature/support derived currencies in currency validation (#3529)
* Support derived currencies in currency validation

* Update changelog
2024-06-29 16:30:40 +02:00
8386fec98a Feature/automatic deletion of unused asset profiles (#3525)
* Automatic deletion of unused asset profiles

* Update changelog
2024-06-29 10:53:25 +02:00
4d3dff3e5b Feature/extend personal finance tools 20240629 (#3528)
* Add Anlage.App

* Add Portfoloo

* Add SharesMaster

* Add Merlin

* Add Holistic

* Add AlphaTrackr

* Add Segmio
2024-06-29 10:53:08 +02:00
76890e63fa Bugfix/fix all time high in benchmarks (#3527)
* Fix all time high

* Update changelog
2024-06-29 10:03:45 +02:00
4fb2aebf4f Release 2.91.0 (#3522) 2024-06-26 20:40:29 +02:00
ed5cd3b978 Feature/upgrade angular to version 18.0.4 (#3520)
* Upgrade Angular to version 18.0.4

* Update changelog
2024-06-26 20:38:26 +02:00
469c1936b4 Bugfix/fix horizontal overflow in historical market data table of admin control panel (#3515)
* Fix horizontal overflow

* Update changelog
2024-06-26 20:38:12 +02:00
8b3cc5c11a Bugfix/fix dialog position on mobile (#3521)
* Fix dialog position on mobile

* Update changelog
2024-06-26 20:19:25 +02:00
ee086638f3 Feature/add benchmarks preset to admin control panel (#3513)
* Add benchmarks preset

* Update changelog
2024-06-26 20:18:53 +02:00
58d1abbd38 Feature/clean up imports (#3514)
* Clean up imports
2024-06-25 19:52:07 +02:00
ba979cbae2 Bugfix/fix addition of manual asset without market data (#3516)
* Provide default value

* Update changelog
2024-06-24 21:24:03 +02:00
8cda43bb63 Bugfix/persist intraday market data only if market state is open (#3509)
* Persist INTRADAY data only if market state is open

* Update changelog
2024-06-23 10:23:03 +02:00
c4499df74c Feature/add wealthy tracker (#3510)
* Add Wealthy Tracker
2024-06-23 10:22:35 +02:00
63 changed files with 2272 additions and 904 deletions

View File

@ -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

View File

@ -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

View File

@ -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_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#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).

View File

@ -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()

View File

@ -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()

View File

@ -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,

View File

@ -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' ||

View File

@ -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;

View File

@ -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';

View File

@ -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';

View File

@ -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;

View File

@ -66,7 +66,6 @@ export class OrderController {
return this.orderService.deleteOrders({
filters,
userCurrency: this.request.user.Settings.settings.baseCurrency,
userId: this.request.user.id
});
}

View File

@ -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 })

View File

@ -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;

View File

@ -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,

View File

@ -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;

View File

@ -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,

View File

@ -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

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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) => {

View File

@ -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

View File

@ -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]

View File

@ -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
};
}
);

View File

@ -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'
);
}

View 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)
);
}
}

View File

@ -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"

View File

@ -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,

View File

@ -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"

View File

@ -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,

View File

@ -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)
);
}
}

View File

@ -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();
}

View File

@ -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({

View File

@ -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)"
>&#64;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)"
>

View File

@ -131,9 +131,9 @@
</p>
<p>
Du erreichst mich per E-Mail unter
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> oder auf
Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> oder auf X
(ehemals Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
Ich freue mich, von dir zu hören.<br />

View File

@ -126,8 +126,9 @@
</p>
<p>
You can reach me by e-mail at
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on X
(formerly Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
I look forward to hearing from you.<br />

View File

@ -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&#64;ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a> if
you are interested, Im happy to discuss ideas.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on X
(formerly Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a> if you are
interested, Im happy to discuss ideas.
</p>
<p>
I would like to say thank you for all your feedback and support

View File

@ -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&#64;ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on X
(formerly Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
I look forward to hearing from you.<br />

View File

@ -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&#64;ghostfol.io</a> or on Twitter
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>. We
are happy to discuss ideas.
<a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a> or on X
(formerly Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>. We are
happy to discuss ideas.
</p>
<p>
We would like to say thank you for all your feedback and support

View File

@ -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_">&#64;ghostfolio_</a> or by
community or get in touch on X (formerly Twitter)
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a> or by
e-mail via <a href="mailto:hi@ghostfol.io">hi&#64;ghostfol.io</a>.
</p>
<p>

View File

@ -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"
>&#64;ghostfolio_</a
>. We look forward to hearing from you!
</p>

View File

@ -122,8 +122,9 @@
>Slack</a
>
community or connect with
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a> on
Twitter. We are happy to discuss ideas and get you involved.
<a href="https://x.com/ghostfolio_">&#64;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>

View File

@ -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
>

View File

@ -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.

View File

@ -89,7 +89,7 @@
>Slack</a
>
community or get in touch on X
<a href="https://twitter.com/ghostfolio_">&#64;ghostfolio_</a>.
<a href="https://x.com/ghostfolio_">&#64;ghostfolio_</a>.
</p>
<p>
We look forward to hearing from you.<br />

View File

@ -122,7 +122,7 @@
>
community,
<a
href="https://twitter.com/ghostfolio_"
href="https://x.com/ghostfolio_"
title="Post to Ghostfolio on X (formerly Twitter)"
>&#64;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)"
>&#64;ghostfolio_</a
>

View File

@ -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)"
>&#64;ghostfolio_</a
>

View File

@ -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)"
>&#64;ghostfolio_</a
>

View File

@ -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>

View File

@ -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) {

View File

@ -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>

View File

@ -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 {

View File

@ -1,4 +1,3 @@
version: '3.9'
services:
ghostfolio:
build: ../

View File

@ -1,4 +1,3 @@
version: '3.9'
services:
postgres:
image: postgres:15

View File

@ -1,4 +1,3 @@
version: '3.9'
services:
ghostfolio:
image: ghostfolio/ghostfolio:latest

View File

@ -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',

View File

@ -1,4 +1,5 @@
export type MarketDataPreset =
| 'BENCHMARKS'
| 'CURRENCIES'
| 'ETF_WITHOUT_COUNTRIES'
| 'ETF_WITHOUT_SECTORS';

View File

@ -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",

View 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"
}
]
}

896
yarn.lock

File diff suppressed because it is too large Load Diff