Merge branch 'main' of gitea.suda.codes:giteauser/ghostfolio-mirror
This commit is contained in:
commit
1b90fba656
12
CHANGELOG.md
12
CHANGELOG.md
@ -5,11 +5,21 @@ 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).
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Introduced filters (`dataSource` and `symbol`) in the accounts endpoint
|
||||
|
||||
### Changed
|
||||
|
||||
- Switched to the accounts endpoint in the holding detail dialog
|
||||
|
||||
## 2.107.1 - 2024-09-12
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the activities filters that occured during destructuring
|
||||
- Fixed an issue in the activities filters that occurred during destructuring
|
||||
|
||||
## 2.107.0 - 2024-09-10
|
||||
|
||||
|
@ -3,6 +3,8 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.servic
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import {
|
||||
@ -26,6 +28,7 @@ import {
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
UseInterceptors
|
||||
} from '@nestjs/common';
|
||||
@ -44,6 +47,7 @@ export class AccountController {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
private readonly accountService: AccountService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
@ -84,13 +88,22 @@ export class AccountController {
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getAllAccounts(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Query('dataSource') filterByDataSource?: string,
|
||||
@Query('symbol') filterBySymbol?: string
|
||||
): Promise<Accounts> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByDataSource,
|
||||
filterBySymbol
|
||||
});
|
||||
|
||||
return this.portfolioService.getAccountsWithAggregations({
|
||||
filters,
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
@ -16,6 +17,7 @@ import { AccountService } from './account.service';
|
||||
exports: [AccountService],
|
||||
imports: [
|
||||
AccountBalanceModule,
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
@ -115,12 +115,33 @@ export class PortfolioService {
|
||||
}): Promise<AccountWithValue[]> {
|
||||
const where: Prisma.AccountWhereInput = { userId };
|
||||
|
||||
const accountFilter = filters?.find(({ type }) => {
|
||||
const filterByAccount = filters?.find(({ type }) => {
|
||||
return type === 'ACCOUNT';
|
||||
});
|
||||
})?.id;
|
||||
|
||||
if (accountFilter) {
|
||||
where.id = accountFilter.id;
|
||||
const filterByDataSource = filters?.find(({ type }) => {
|
||||
return type === 'DATA_SOURCE';
|
||||
})?.id;
|
||||
|
||||
const filterBySymbol = filters?.find(({ type }) => {
|
||||
return type === 'SYMBOL';
|
||||
})?.id;
|
||||
|
||||
if (filterByAccount) {
|
||||
where.id = filterByAccount;
|
||||
}
|
||||
|
||||
if (filterByDataSource && filterBySymbol) {
|
||||
where.Order = {
|
||||
some: {
|
||||
SymbolProfile: {
|
||||
AND: [
|
||||
{ dataSource: <DataSource>filterByDataSource },
|
||||
{ symbol: filterBySymbol }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const [accounts, details] = await Promise.all([
|
||||
@ -604,7 +625,6 @@ export class PortfolioService {
|
||||
|
||||
if (activities.length === 0) {
|
||||
return {
|
||||
accounts: [],
|
||||
averagePrice: undefined,
|
||||
dataProviderInfo: undefined,
|
||||
dividendInBaseCurrency: undefined,
|
||||
@ -678,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)
|
||||
@ -767,7 +778,6 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
return {
|
||||
accounts,
|
||||
firstBuyDate,
|
||||
marketPrice,
|
||||
maxPrice,
|
||||
@ -862,7 +872,6 @@ export class PortfolioService {
|
||||
maxPrice,
|
||||
minPrice,
|
||||
SymbolProfile,
|
||||
accounts: [],
|
||||
averagePrice: 0,
|
||||
dataProviderInfo: undefined,
|
||||
dividendInBaseCurrency: 0,
|
||||
|
@ -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 {
|
||||
|
@ -9,6 +9,7 @@ import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
EnhancedSymbolProfile,
|
||||
Filter,
|
||||
LineChartItem,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -152,6 +153,11 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
tags: <string[]>[]
|
||||
});
|
||||
|
||||
const filters: Filter[] = [
|
||||
{ id: this.data.dataSource, type: 'DATA_SOURCE' },
|
||||
{ id: this.data.symbol, type: 'SYMBOL' }
|
||||
];
|
||||
|
||||
this.tagsAvailable = tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
@ -173,12 +179,20 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
.subscribe();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchAccounts({
|
||||
filters
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ accounts }) => {
|
||||
this.accounts = accounts;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchActivities({
|
||||
filters: [
|
||||
{ id: this.data.dataSource, type: 'DATA_SOURCE' },
|
||||
{ id: this.data.symbol, type: 'SYMBOL' }
|
||||
],
|
||||
filters,
|
||||
sortColumn: this.sortColumn,
|
||||
sortDirection: this.sortDirection
|
||||
})
|
||||
@ -197,7 +211,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(
|
||||
({
|
||||
accounts,
|
||||
averagePrice,
|
||||
dataProviderInfo,
|
||||
dividendInBaseCurrency,
|
||||
@ -219,7 +232,6 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
|
||||
transactionCount,
|
||||
value
|
||||
}) => {
|
||||
this.accounts = accounts;
|
||||
this.averagePrice = averagePrice;
|
||||
this.benchmarkDataItems = [];
|
||||
this.countries = {};
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface IRuleSettingsDialogParams {
|
||||
rule: PortfolioReportRule;
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
@ -0,0 +1,2 @@
|
||||
:host {
|
||||
}
|
@ -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>
|
||||
|
@ -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;
|
||||
|
||||
public ngOnInit() {}
|
||||
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);
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
@ -173,8 +173,10 @@ export class DataService {
|
||||
);
|
||||
}
|
||||
|
||||
public fetchAccounts() {
|
||||
return this.http.get<Accounts>('/api/v1/account');
|
||||
public fetchAccounts({ filters }: { filters?: Filter[] } = {}) {
|
||||
const params = this.buildFiltersAsQueryParams({ filters });
|
||||
|
||||
return this.http.get<Accounts>('/api/v1/account', { params });
|
||||
}
|
||||
|
||||
public fetchActivities({
|
||||
|
@ -3,5 +3,9 @@ export interface PortfolioReportRule {
|
||||
isActive: boolean;
|
||||
key: string;
|
||||
name: string;
|
||||
settings?: {
|
||||
thresholdMax?: number;
|
||||
thresholdMin?: number;
|
||||
};
|
||||
value?: boolean;
|
||||
}
|
||||
|
@ -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…
x
Reference in New Issue
Block a user