Feature/extend assistant with search for asset profile (#2499)

* Extend assistant with search for asset profile

* Extend search results by currency, symbol and asset sub class

* Update changelog
pull/2501/head
Thomas Kaul 12 months ago committed by GitHub
parent 7243090c0e
commit 30e561c06f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the endpoint `GET api/v1/account/:id/balances` which provides historical cash balances - Added the endpoint `GET api/v1/account/:id/balances` which provides historical cash balances
- Added support to search for an asset profile by `isin`, `name` and `symbol` as an administrator (experimental)
### Changed ### Changed

@ -1,9 +1,9 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto'; import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
DEFAULT_PAGE_SIZE,
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
@ -12,8 +12,7 @@ import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
EnhancedSymbolProfile, EnhancedSymbolProfile
Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { import type {
@ -50,6 +49,7 @@ import { UpdateMarketDataDto } from './update-market-data.dto';
export class AdminController { export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -255,6 +255,7 @@ export class AdminController {
public async getMarketData( public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset, @Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string, @Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@ -272,16 +273,10 @@ export class AdminController {
); );
} }
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
const filters: Filter[] = [ filterBySearchQuery
...assetSubClasses.map((assetSubClass) => { });
return <Filter>{
id: assetSubClass,
type: 'ASSET_SUB_CLASS'
};
})
];
return this.adminService.getMarketData({ return this.adminService.getMarketData({
filters, filters,

@ -1,4 +1,5 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ApiModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,

@ -131,10 +131,14 @@ export class AdminService {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} }
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy( const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters, filters,
(filter) => { ({ type }) => {
return filter.type; return type;
} }
); );
@ -147,6 +151,14 @@ export class AdminService {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} }
if (searchQuery) {
where.OR = [
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
}
if (sortColumn) { if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }]; orderBy = [{ [sortColumn]: sortDirection }];
@ -173,7 +185,9 @@ export class AdminService {
assetSubClass: true, assetSubClass: true,
comment: true, comment: true,
countries: true, countries: true,
currency: true,
dataSource: true, dataSource: true,
name: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
select: { date: true }, select: { date: true },
@ -194,7 +208,9 @@ export class AdminService {
assetSubClass, assetSubClass,
comment, comment,
countries, countries,
currency,
dataSource, dataSource,
name,
Order, Order,
sectors, sectors,
symbol symbol
@ -213,8 +229,10 @@ export class AdminService {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
currency,
countriesCount, countriesCount,
dataSource, dataSource,
name,
symbol, symbol,
marketDataItemCount, marketDataItemCount,
sectorsCount, sectorsCount,
@ -341,6 +359,8 @@ export class AdminService {
symbol, symbol,
assetClass: 'CASH', assetClass: 'CASH',
countriesCount: 0, countriesCount: 0,
currency: symbol.replace(DEFAULT_CURRENCY, ''),
name: symbol,
sectorsCount: 0 sectorsCount: 0
}; };
}); });

@ -1088,6 +1088,7 @@ export class PortfolioService {
return { return {
...position, ...position,
assetClass: symbolProfileMap[position.symbol].assetClass, assetClass: symbolProfileMap[position.symbol].assetClass,
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
averagePrice: new Big(position.averagePrice).toNumber(), averagePrice: new Big(position.averagePrice).toNumber(),
grossPerformance: position.grossPerformance?.toNumber() ?? null, grossPerformance: position.grossPerformance?.toNumber() ?? null,
grossPerformancePercentage: grossPerformancePercentage:

@ -8,16 +8,19 @@ export class ApiService {
public buildFiltersFromQueryParams({ public buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByAssetSubClasses,
filterBySearchQuery, filterBySearchQuery,
filterByTags filterByTags
}: { }: {
filterByAccounts?: string; filterByAccounts?: string;
filterByAssetClasses?: string; filterByAssetClasses?: string;
filterByAssetSubClasses?: string;
filterBySearchQuery?: string; filterBySearchQuery?: string;
filterByTags?: string; filterByTags?: string;
}): Filter[] { }): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? []; const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const searchQuery = filterBySearchQuery?.toLowerCase(); const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? []; const tagIds = filterByTags?.split(',') ?? [];
@ -34,6 +37,12 @@ export class ApiService {
type: 'ASSET_CLASS' type: 'ASSET_CLASS'
}; };
}), }),
...assetSubClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_SUB_CLASS'
};
}),
{ {
id: searchQuery, id: searchQuery,
type: 'SEARCH_QUERY' type: 'SEARCH_QUERY'

@ -131,6 +131,9 @@
<gf-assistant <gf-assistant
#assistant #assistant
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToAccessAdminControl]="
hasPermissionToAccessAdminControl
"
(closed)="closeAssistant()" (closed)="closeAssistant()"
/> />
</mat-menu> </mat-menu>

@ -9,9 +9,11 @@ export interface AdminMarketDataItem {
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass;
countriesCount: number; countriesCount: number;
currency: string;
dataSource: DataSource; dataSource: DataSource;
date?: Date; date?: Date;
marketDataItemCount: number; marketDataItemCount: number;
name: string;
sectorsCount: number; sectorsCount: number;
symbol: string; symbol: string;
} }

@ -1,9 +1,9 @@
import { AssetClass, DataSource } from '@prisma/client'; import { MarketState } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { MarketState } from '../types';
export interface Position { export interface Position {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass;
averagePrice: number; averagePrice: number;
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;

@ -7,10 +7,12 @@ import {
EventEmitter, EventEmitter,
HostBinding, HostBinding,
Input, Input,
OnChanges,
Output, Output,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { Position } from '@ghostfolio/common/interfaces'; import { Params } from '@angular/router';
import { ISearchResultItem } from '@ghostfolio/ui/assistant/interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -18,22 +20,46 @@ import { Position } from '@ghostfolio/common/interfaces';
templateUrl: './assistant-list-item.html', templateUrl: './assistant-list-item.html',
styleUrls: ['./assistant-list-item.scss'] styleUrls: ['./assistant-list-item.scss']
}) })
export class AssistantListItemComponent implements FocusableOption { export class AssistantListItemComponent implements FocusableOption, OnChanges {
@HostBinding('attr.tabindex') tabindex = -1; @HostBinding('attr.tabindex') tabindex = -1;
@HostBinding('class.has-focus') get getHasFocus() { @HostBinding('class.has-focus') get getHasFocus() {
return this.hasFocus; return this.hasFocus;
} }
@Input() holding: Position; @Input() item: ISearchResultItem;
@Input() mode: 'assetProfile' | 'holding';
@Output() clicked = new EventEmitter<void>(); @Output() clicked = new EventEmitter<void>();
@ViewChild('link') public linkElement: ElementRef; @ViewChild('link') public linkElement: ElementRef;
public hasFocus = false; public hasFocus = false;
public queryParams: Params;
public routerLink: string[];
public constructor(private changeDetectorRef: ChangeDetectorRef) {} public constructor(private changeDetectorRef: ChangeDetectorRef) {}
public ngOnChanges() {
const dataSource = this.item?.dataSource;
const symbol = this.item?.symbol;
if (this.mode === 'assetProfile') {
this.queryParams = {
dataSource,
symbol,
assetProfileDialog: true
};
this.routerLink = ['/admin', 'market-data'];
} else if (this.mode === 'holding') {
this.queryParams = {
dataSource,
symbol,
positionDetailDialog: true
};
this.routerLink = ['/portfolio', 'holdings'];
}
}
public focus() { public focus() {
this.hasFocus = true; this.hasFocus = true;

@ -1,12 +1,16 @@
<a <a
#link #link
class="d-block px-2 py-1 text-truncate" class="d-block line-height-1 px-2 py-1 text-truncate"
[queryParams]="{ [queryParams]="queryParams"
dataSource: holding?.dataSource, [routerLink]="routerLink"
positionDetailDialog: true,
symbol: holding?.symbol
}"
[routerLink]="['/portfolio', 'holdings']"
(click)="onClick()" (click)="onClick()"
>{{ holding?.name }}</a ><span><b>{{ item?.name }}</b></span>
<br />
<small class="text-muted"
>{{ item?.symbol | gfSymbol }} · {{ item?.currency }}<ng-container
*ngIf="item?.assetSubClassString"
>
· {{ item?.assetSubClassString }}</ng-container
></small
></a
> >

@ -1,12 +1,13 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { AssistantListItemComponent } from './assistant-list-item.component'; import { AssistantListItemComponent } from './assistant-list-item.component';
@NgModule({ @NgModule({
declarations: [AssistantListItemComponent], declarations: [AssistantListItemComponent],
exports: [AssistantListItemComponent], exports: [AssistantListItemComponent],
imports: [CommonModule, RouterModule] imports: [CommonModule, GfSymbolModule, RouterModule]
}) })
export class GfAssistantListItemModule {} export class GfAssistantListItemModule {}

@ -16,9 +16,10 @@ import {
} from '@angular/core'; } from '@angular/core';
import { FormControl } from '@angular/forms'; import { FormControl } from '@angular/forms';
import { MatMenuTrigger } from '@angular/material/menu'; import { MatMenuTrigger } from '@angular/material/menu';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { Position } from '@ghostfolio/common/interfaces'; import { translate } from '@ghostfolio/ui/i18n';
import { EMPTY, Subject, lastValueFrom } from 'rxjs'; import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
import { import {
catchError, catchError,
debounceTime, debounceTime,
@ -29,13 +30,13 @@ import {
} from 'rxjs/operators'; } from 'rxjs/operators';
import { AssistantListItemComponent } from './assistant-list-item/assistant-list-item.component'; import { AssistantListItemComponent } from './assistant-list-item/assistant-list-item.component';
import { ISearchResults } from './interfaces/interfaces'; import { ISearchResultItem, ISearchResults } from './interfaces/interfaces';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-assistant', selector: 'gf-assistant',
templateUrl: './assistant.html', styleUrls: ['./assistant.scss'],
styleUrls: ['./assistant.scss'] templateUrl: './assistant.html'
}) })
export class AssistantComponent implements OnDestroy, OnInit { export class AssistantComponent implements OnDestroy, OnInit {
@HostListener('document:keydown', ['$event']) onKeydown( @HostListener('document:keydown', ['$event']) onKeydown(
@ -71,6 +72,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
} }
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToAccessAdminControl: boolean;
@Output() closed = new EventEmitter<void>(); @Output() closed = new EventEmitter<void>();
@ -87,6 +89,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
public placeholder = $localize`Find holding...`; public placeholder = $localize`Find holding...`;
public searchFormControl = new FormControl(''); public searchFormControl = new FormControl('');
public searchResults: ISearchResults = { public searchResults: ISearchResults = {
assetProfiles: [],
holdings: [] holdings: []
}; };
@ -94,6 +97,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService private dataService: DataService
) {} ) {}
@ -104,6 +108,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
map((searchTerm) => { map((searchTerm) => {
this.isLoading = true; this.isLoading = true;
this.searchResults = { this.searchResults = {
assetProfiles: [],
holdings: [] holdings: []
}; };
@ -115,6 +120,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
distinctUntilChanged(), distinctUntilChanged(),
mergeMap(async (searchTerm) => { mergeMap(async (searchTerm) => {
const result = <ISearchResults>{ const result = <ISearchResults>{
assetProfiles: [],
holdings: [] holdings: []
}; };
@ -140,6 +146,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
this.isLoading = true; this.isLoading = true;
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap();
this.searchResults = { this.searchResults = {
assetProfiles: [],
holdings: [] holdings: []
}; };
@ -180,10 +187,23 @@ export class AssistantComponent implements OnDestroy, OnInit {
} }
private async getSearchResults(aSearchTerm: string) { private async getSearchResults(aSearchTerm: string) {
let holdings: Position[] = []; let assetProfiles: ISearchResultItem[] = [];
let holdings: ISearchResultItem[] = [];
if (this.hasPermissionToAccessAdminControl) {
try { try {
holdings = await lastValueFrom(this.searchHolding(aSearchTerm)); assetProfiles = await lastValueFrom(
this.searchAssetProfiles(aSearchTerm)
);
assetProfiles = assetProfiles.slice(
0,
AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
);
} catch {}
}
try {
holdings = await lastValueFrom(this.searchHoldings(aSearchTerm));
holdings = holdings.slice( holdings = holdings.slice(
0, 0,
AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
@ -191,11 +211,46 @@ export class AssistantComponent implements OnDestroy, OnInit {
} catch {} } catch {}
return { return {
assetProfiles,
holdings holdings
}; };
} }
private searchHolding(aSearchTerm: string) { private searchAssetProfiles(
aSearchTerm: string
): Observable<ISearchResultItem[]> {
return this.adminService
.fetchAdminMarketData({
filters: [
{
id: aSearchTerm,
type: 'SEARCH_QUERY'
}
],
take: AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
})
.pipe(
catchError(() => {
return EMPTY;
}),
map(({ marketData }) => {
return marketData.map(
({ assetSubClass, currency, dataSource, name, symbol }) => {
return {
currency,
dataSource,
name,
symbol,
assetSubClassString: translate(assetSubClass)
};
}
);
}),
takeUntil(this.unsubscribeSubject)
);
}
private searchHoldings(aSearchTerm: string): Observable<ISearchResultItem[]> {
return this.dataService return this.dataService
.fetchPositions({ .fetchPositions({
filters: [ filters: [
@ -211,7 +266,17 @@ export class AssistantComponent implements OnDestroy, OnInit {
return EMPTY; return EMPTY;
}), }),
map(({ positions }) => { map(({ positions }) => {
return positions; return positions.map(
({ assetSubClass, currency, dataSource, name, symbol }) => {
return {
currency,
dataSource,
name,
symbol,
assetSubClassString: translate(assetSubClass)
};
}
);
}), }),
takeUntil(this.unsubscribeSubject) takeUntil(this.unsubscribeSubject)
); );

@ -45,8 +45,9 @@
<div> <div>
<div class="h6 mb-1 px-2" i18n>Holdings</div> <div class="h6 mb-1 px-2" i18n>Holdings</div>
<gf-assistant-list-item <gf-assistant-list-item
*ngFor="let holding of searchResults?.holdings" *ngFor="let searchResultItem of searchResults?.holdings"
[holding]="holding" mode="holding"
[item]="searchResultItem"
(clicked)="onCloseAssistant()" (clicked)="onCloseAssistant()"
/> />
<ng-container *ngIf="searchResults?.holdings?.length === 0"> <ng-container *ngIf="searchResults?.holdings?.length === 0">
@ -62,5 +63,26 @@
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div> <div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container> </ng-container>
</div> </div>
<div *ngIf="hasPermissionToAccessAdminControl" class="mt-3">
<div class="h6 mb-1 px-2" i18n>Asset Profiles</div>
<gf-assistant-list-item
*ngFor="let searchResultItem of searchResults?.assetProfiles"
mode="assetProfile"
[item]="searchResultItem"
(clicked)="onCloseAssistant()"
/>
<ng-container *ngIf="searchResults?.assetProfiles?.length === 0">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
class="mx-2"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
</ng-container>
</div>
</div> </div>
</div> </div>

@ -1,5 +1,12 @@
import { Position } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
export interface ISearchResultItem extends UniqueAsset {
assetSubClassString: string;
currency: string;
name: string;
}
export interface ISearchResults { export interface ISearchResults {
holdings: Position[]; assetProfiles: ISearchResultItem[];
holdings: ISearchResultItem[];
} }

Loading…
Cancel
Save