Browse Source

Merge branch 'main' into feature/add-copy-to-clipboard-action-to-access-table-component

pull/3768/head
Thomas Kaul 11 months ago
committed by GitHub
parent
commit
b0b07f43d1
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      CHANGELOG.md
  2. 160
      apps/api/src/app/admin/admin.service.ts
  3. 3
      apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts
  4. 14
      apps/api/src/app/portfolio/portfolio.service.ts
  5. 21
      apps/api/src/app/portfolio/rules.service.ts
  6. 1
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  7. 15
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  8. 2
      apps/client/src/app/components/admin-market-data/admin-market-data.module.ts
  9. 5
      apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts
  10. 40
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts
  11. 23
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  12. 2
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.scss
  13. 5
      apps/client/src/app/components/rule/rule.component.html
  14. 49
      apps/client/src/app/components/rule/rule.component.ts
  15. 5
      apps/client/src/app/components/toggle/toggle.component.html
  16. 4
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  17. 1
      libs/common/src/lib/interfaces/admin-market-data.interface.ts
  18. 4
      libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts
  19. 53
      libs/common/src/lib/personal-finance-tools.ts

3
CHANGELOG.md

@ -9,11 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support for bonds in the import dividends dialog
- Added a _Copy link to clipboard_ action to the access table to share the portfolio
- Added the current market price column to the historical market data table of the admin control
- Introduced filters (`dataSource` and `symbol`) in the accounts endpoint
### Changed
- Improved the usability of the toggle component
- Switched to the accounts endpoint in the holding detail dialog
## 2.107.1 - 2024-09-12

160
apps/api/src/app/admin/admin.service.ts

@ -15,7 +15,11 @@ import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import { isCurrency, getCurrencyFromSymbol } from '@ghostfolio/common/helper';
import {
getAssetProfileIdentifier,
getCurrencyFromSymbol,
isCurrency
} from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
@ -261,6 +265,37 @@ export class AdminService {
this.prismaService.symbolProfile.count({ where })
]);
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: assetProfiles.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: assetProfiles.map(({ symbol }) => {
return symbol;
})
}
}
});
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(
async ({
@ -281,6 +316,11 @@ export class AdminService {
const countriesCount = countries
? Object.keys(countries).length
: 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
@ -288,6 +328,7 @@ export class AdminService {
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
@ -298,6 +339,7 @@ export class AdminService {
countriesCount,
dataSource,
id,
lastMarketPrice,
name,
symbol,
marketDataItemCount,
@ -511,48 +553,86 @@ export class AdminService {
}
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
const marketDataPromise: Promise<AdminMarketDataItem>[] =
this.exchangeRateDataService
.getCurrencyPairs()
.map(async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency));
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
const [lastMarketPrices, marketDataItems] = await Promise.all([
this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: currencyPairs.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: currencyPairs.map(({ symbol }) => {
return symbol;
})
}
}
}),
this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
})
]);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const lastMarketPriceMap = new Map<string, number>();
return {
activitiesCount,
currency,
dataSource,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
name: symbol,
sectorsCount: 0
};
});
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map(
async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.orderService.getStatisticsByCurrency(currency));
}
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
activitiesCount,
currency,
dataSource,
lastMarketPrice,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
name: symbol,
sectorsCount: 0
};
}
);
const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length };

3
apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts

@ -5,10 +5,9 @@ import {
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { Account, Tag } from '@prisma/client';
import { Tag } from '@prisma/client';
export interface PortfolioHoldingDetail {
accounts: Account[];
averagePrice: number;
dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number;

14
apps/api/src/app/portfolio/portfolio.service.ts

@ -73,7 +73,7 @@ import {
parseISO,
set
} from 'date-fns';
import { isEmpty, last, uniq, uniqBy } from 'lodash';
import { isEmpty, last, uniq } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import {
@ -625,7 +625,6 @@ export class PortfolioService {
if (activities.length === 0) {
return {
accounts: [],
averagePrice: undefined,
dataProviderInfo: undefined,
dividendInBaseCurrency: undefined,
@ -699,15 +698,6 @@ export class PortfolioService {
);
});
const accounts: PortfolioHoldingDetail['accounts'] = uniqBy(
activitiesOfPosition.filter(({ Account }) => {
return Account;
}),
'Account.id'
).map(({ Account }) => {
return Account;
});
const dividendYieldPercent = getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
netPerformancePercentage: timeWeightedInvestment.eq(0)
@ -788,7 +778,6 @@ export class PortfolioService {
}
return {
accounts,
firstBuyDate,
marketPrice,
maxPrice,
@ -883,7 +872,6 @@ export class PortfolioService {
maxPrice,
minPrice,
SymbolProfile,
accounts: [],
averagePrice: 0,
dataProviderInfo: undefined,
dividendInBaseCurrency: 0,

21
apps/api/src/app/portfolio/rules.service.ts

@ -1,6 +1,9 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { UserSettings } from '@ghostfolio/common/interfaces';
import {
PortfolioReportRule,
UserSettings
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@ -11,19 +14,23 @@ export class RulesService {
public async evaluate<T extends RuleSettings>(
aRules: Rule<T>[],
aUserSettings: UserSettings
) {
): Promise<PortfolioReportRule[]> {
return aRules.map((rule) => {
if (rule.getSettings(aUserSettings)?.isActive) {
const { evaluation, value } = rule.evaluate(
rule.getSettings(aUserSettings)
);
const settings = rule.getSettings(aUserSettings);
if (settings?.isActive) {
const { evaluation, value } = rule.evaluate(settings);
return {
evaluation,
value,
isActive: true,
key: rule.getKey(),
name: rule.getName()
name: rule.getName(),
settings: <PortfolioReportRule['settings']>{
thresholdMax: settings['thresholdMax'],
thresholdMin: settings['thresholdMin']
}
};
} else {
return {

1
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -142,6 +142,7 @@ export class AdminMarketDataComponent
'dataSource',
'assetClass',
'assetSubClass',
'lastMarketPrice',
'date',
'activitiesCount',
'marketDataItemCount',

15
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -99,6 +99,21 @@
</td>
</ng-container>
<ng-container matColumnDef="lastMarketPrice">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Market Price</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="user?.settings?.locale"
[value]="element.lastMarketPrice ?? ''"
/>
</div>
</td>
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>First Activity</ng-container>

2
apps/client/src/app/components/admin-market-data/admin-market-data.module.ts

@ -1,6 +1,7 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { GfValueComponent } from '@ghostfolio/ui/value';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
@ -27,6 +28,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
GfCreateAssetProfileDialogModule,
GfPremiumIndicatorComponent,
GfSymbolModule,
GfValueComponent,
MatButtonModule,
MatCheckboxModule,
MatMenuModule,

5
apps/client/src/app/components/rule/rule-settings-dialog/interfaces/interfaces.ts

@ -0,0 +1,5 @@
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
export interface IRuleSettingsDialogParams {
rule: PortfolioReportRule;
}

40
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.component.ts

@ -0,0 +1,40 @@
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common';
import { Component, Inject } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import {
MAT_DIALOG_DATA,
MatDialogModule,
MatDialogRef
} from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { IRuleSettingsDialogParams } from './interfaces/interfaces';
@Component({
imports: [
CommonModule,
MatButtonModule,
MatDialogModule,
MatFormFieldModule,
MatInputModule
],
selector: 'gf-rule-settings-dialog',
standalone: true,
styleUrls: ['./rule-settings-dialog.scss'],
templateUrl: './rule-settings-dialog.html'
})
export class GfRuleSettingsDialogComponent {
public settings: PortfolioReportRule['settings'];
public constructor(
@Inject(MAT_DIALOG_DATA) public data: IRuleSettingsDialogParams,
public dialogRef: MatDialogRef<GfRuleSettingsDialogComponent>
) {
console.log(this.data.rule);
this.settings = this.data.rule.settings;
}
}

23
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -0,0 +1,23 @@
<div mat-dialog-title>{{ data.rule.name }}</div>
<div class="py-3" mat-dialog-content>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Threshold Min</mat-label>
<input matInput name="thresholdMin" type="number" />
</mat-form-field>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Threshold Max</mat-label>
<input matInput name="thresholdMax" type="number" />
</mat-form-field>
</div>
<div align="end" mat-dialog-actions>
<button i18n mat-button (click)="dialogRef.close()">Close</button>
<button
color="primary"
mat-flat-button
(click)="dialogRef.close({ settings })"
>
<ng-container i18n>Save</ng-container>
</button>
</div>

2
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.scss

@ -0,0 +1,2 @@
:host {
}

5
apps/client/src/app/components/rule/rule.component.html

@ -62,6 +62,11 @@
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #rulesMenu="matMenu" xPosition="before">
@if (rule?.isActive && !isEmpty(rule.settings) && false) {
<button mat-menu-item (click)="onCustomizeRule(rule)">
<ng-container i18n>Customize</ng-container>...
</button>
}
<button mat-menu-item (click)="onUpdateRule(rule)">
@if (rule?.isActive) {
<ng-container i18n>Deactivate</ng-container>

49
apps/client/src/app/components/rule/rule.component.ts

@ -9,6 +9,13 @@ import {
OnInit,
Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { isEmpty } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { IRuleSettingsDialogParams } from './rule-settings-dialog/interfaces/interfaces';
import { GfRuleSettingsDialogComponent } from './rule-settings-dialog/rule-settings-dialog.component';
@Component({
selector: 'gf-rule',
@ -23,9 +30,42 @@ export class RuleComponent implements OnInit {
@Output() ruleUpdated = new EventEmitter<UpdateUserSettingDto>();
public constructor() {}
public isEmpty = isEmpty;
private deviceType: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
private deviceService: DeviceDetectorService,
private dialog: MatDialog
) {}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
public onCustomizeRule(rule: PortfolioReportRule) {
const dialogRef = this.dialog.open(GfRuleSettingsDialogComponent, {
data: <IRuleSettingsDialogParams>{
rule
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({ settings }: { settings: PortfolioReportRule['settings'] }) => {
if (settings) {
console.log(settings);
public ngOnInit() {}
// TODO
// this.ruleUpdated.emit(settings);
}
}
);
}
public onUpdateRule(rule: PortfolioReportRule) {
const settings: UpdateUserSettingDto = {
@ -36,4 +76,9 @@ export class RuleComponent implements OnInit {
this.ruleUpdated.emit(settings);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

5
apps/client/src/app/components/toggle/toggle.component.html

@ -7,7 +7,10 @@
<mat-radio-button
class="d-inline-flex"
[disabled]="isLoading"
[ngClass]="{ 'cursor-pointer': !isLoading }"
[ngClass]="{
'cursor-default': option.value === optionFormControl.value,
'cursor-pointer': !isLoading && option.value !== optionFormControl.value
}"
[value]="option.value"
>{{ option.label }}</mat-radio-button
>

4
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -93,6 +93,10 @@ export class ImportActivitiesDialog implements OnDestroy {
{
id: AssetClass.EQUITY,
type: 'ASSET_CLASS'
},
{
id: AssetClass.FIXED_INCOME,
type: 'ASSET_CLASS'
}
],
range: 'max'

1
libs/common/src/lib/interfaces/admin-market-data.interface.ts

@ -16,6 +16,7 @@ export interface AdminMarketDataItem {
id: string;
isBenchmark?: boolean;
isUsedByUsersWithSubscription?: boolean;
lastMarketPrice: number;
marketDataItemCount: number;
name: string;
sectorsCount: number;

4
libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts

@ -3,5 +3,9 @@ export interface PortfolioReportRule {
isActive: boolean;
key: string;
name: string;
settings?: {
thresholdMax?: number;
thresholdMin?: number;
};
value?: boolean;
}

53
libs/common/src/lib/personal-finance-tools.ts

@ -44,6 +44,15 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$120',
slogan: 'Analyze and track your portfolio.'
},
{
hasFreePlan: false,
hasSelfHostingAbility: true,
key: 'banktivity',
name: 'Banktivity',
origin: 'United States',
pricingPerYear: '$59.99',
slogan: 'Proactive money management app for macOS & iOS'
},
{
founded: 2022,
hasFreePlan: true,
@ -62,6 +71,17 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$100',
slogan: 'Stock Portfolio Tracker for Smart Investors'
},
{
founded: 2007,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'buxfer',
name: 'Buxfer',
origin: 'United States',
pricingPerYear: '$48',
regions: ['Global'],
slogan: 'Take control of your financial future'
},
{
founded: 2013,
hasFreePlan: true,
@ -329,6 +349,13 @@ export const personalFinanceTools: Product[] = [
regions: ['Global'],
slogan: 'Track your investments'
},
{
founded: 2010,
key: 'masttro',
name: 'Masttro',
origin: 'United States',
slogan: 'Your platform for wealth in full view'
},
{
founded: 2021,
hasSelfHostingAbility: false,
@ -352,6 +379,14 @@ export const personalFinanceTools: Product[] = [
regions: ['Canada', 'United States'],
slogan: 'The smartest way to track your crypto'
},
{
founded: 1991,
hasSelfHostingAbility: true,
key: 'microsoft-money',
name: 'Microsoft Money',
note: 'Microsoft Money was discontinued in 2010',
origin: 'United States'
},
{
founded: 2019,
hasFreePlan: false,
@ -362,6 +397,16 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$99.99',
slogan: 'The modern way to manage your money'
},
{
founded: 1999,
hasFreePlan: false,
hasSelfHostingAbility: true,
key: 'moneydance',
name: 'Moneydance',
origin: 'Scotland',
pricingPerYear: '$100',
slogan: 'Personal Finance Manager for Mac, Windows, and Linux'
},
{
hasFreePlan: false,
hasSelfHostingAbility: false,
@ -640,6 +685,14 @@ export const personalFinanceTools: Product[] = [
pricingPerYear: '$50',
slogan: 'See all your investments in one place'
},
{
founded: 2018,
hasFreePlan: true,
key: 'wealthposition',
name: 'WealthPosition',
pricingPerYear: '$60',
slogan: 'Personal Finance & Budgeting App'
},
{
founded: 2018,
hasSelfHostingAbility: false,

Loading…
Cancel
Save