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
This commit is contained in:
parent
7243090c0e
commit
30e561c06f
@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- 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
|
||||
|
||||
|
@ -1,9 +1,9 @@
|
||||
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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
GATHER_ASSET_PROFILE_PROCESS,
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
@ -12,8 +12,7 @@ import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile,
|
||||
Filter
|
||||
EnhancedSymbolProfile
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
@ -50,6 +49,7 @@ import { UpdateMarketDataDto } from './update-market-data.dto';
|
||||
export class AdminController {
|
||||
public constructor(
|
||||
private readonly adminService: AdminService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
@ -255,6 +255,7 @@ export class AdminController {
|
||||
public async getMarketData(
|
||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||
@Query('presetId') presetId?: MarketDataPreset,
|
||||
@Query('query') filterBySearchQuery?: string,
|
||||
@Query('skip') skip?: number,
|
||||
@Query('sortColumn') sortColumn?: string,
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@ -272,16 +273,10 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||
|
||||
const filters: Filter[] = [
|
||||
...assetSubClasses.map((assetSubClass) => {
|
||||
return <Filter>{
|
||||
id: assetSubClass,
|
||||
type: 'ASSET_SUB_CLASS'
|
||||
};
|
||||
})
|
||||
];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAssetSubClasses,
|
||||
filterBySearchQuery
|
||||
});
|
||||
|
||||
return this.adminService.getMarketData({
|
||||
filters,
|
||||
|
@ -1,4 +1,5 @@
|
||||
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 { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
@ -15,6 +16,7 @@ import { QueueModule } from './queue/queue.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
|
@ -131,10 +131,14 @@ export class AdminService {
|
||||
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
|
||||
}
|
||||
|
||||
const searchQuery = filters.find(({ type }) => {
|
||||
return type === 'SEARCH_QUERY';
|
||||
})?.id;
|
||||
|
||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||
filters,
|
||||
(filter) => {
|
||||
return filter.type;
|
||||
({ type }) => {
|
||||
return type;
|
||||
}
|
||||
);
|
||||
|
||||
@ -147,6 +151,14 @@ export class AdminService {
|
||||
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) {
|
||||
orderBy = [{ [sortColumn]: sortDirection }];
|
||||
|
||||
@ -173,7 +185,9 @@ export class AdminService {
|
||||
assetSubClass: true,
|
||||
comment: true,
|
||||
countries: true,
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
@ -194,7 +208,9 @@ export class AdminService {
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
Order,
|
||||
sectors,
|
||||
symbol
|
||||
@ -213,8 +229,10 @@ export class AdminService {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
currency,
|
||||
countriesCount,
|
||||
dataSource,
|
||||
name,
|
||||
symbol,
|
||||
marketDataItemCount,
|
||||
sectorsCount,
|
||||
@ -341,6 +359,8 @@ export class AdminService {
|
||||
symbol,
|
||||
assetClass: 'CASH',
|
||||
countriesCount: 0,
|
||||
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
||||
name: symbol,
|
||||
sectorsCount: 0
|
||||
};
|
||||
});
|
||||
|
@ -1088,6 +1088,7 @@ export class PortfolioService {
|
||||
return {
|
||||
...position,
|
||||
assetClass: symbolProfileMap[position.symbol].assetClass,
|
||||
assetSubClass: symbolProfileMap[position.symbol].assetSubClass,
|
||||
averagePrice: new Big(position.averagePrice).toNumber(),
|
||||
grossPerformance: position.grossPerformance?.toNumber() ?? null,
|
||||
grossPerformancePercentage:
|
||||
|
@ -8,16 +8,19 @@ export class ApiService {
|
||||
public buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByAssetSubClasses,
|
||||
filterBySearchQuery,
|
||||
filterByTags
|
||||
}: {
|
||||
filterByAccounts?: string;
|
||||
filterByAssetClasses?: string;
|
||||
filterByAssetSubClasses?: string;
|
||||
filterBySearchQuery?: string;
|
||||
filterByTags?: string;
|
||||
}): Filter[] {
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||
const searchQuery = filterBySearchQuery?.toLowerCase();
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
|
||||
@ -34,6 +37,12 @@ export class ApiService {
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...assetSubClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_SUB_CLASS'
|
||||
};
|
||||
}),
|
||||
{
|
||||
id: searchQuery,
|
||||
type: 'SEARCH_QUERY'
|
||||
|
@ -131,6 +131,9 @@
|
||||
<gf-assistant
|
||||
#assistant
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToAccessAdminControl]="
|
||||
hasPermissionToAccessAdminControl
|
||||
"
|
||||
(closed)="closeAssistant()"
|
||||
/>
|
||||
</mat-menu>
|
||||
|
@ -9,9 +9,11 @@ export interface AdminMarketDataItem {
|
||||
assetClass?: AssetClass;
|
||||
assetSubClass?: AssetSubClass;
|
||||
countriesCount: number;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
date?: Date;
|
||||
marketDataItemCount: number;
|
||||
name: string;
|
||||
sectorsCount: number;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { AssetClass, DataSource } from '@prisma/client';
|
||||
|
||||
import { MarketState } from '../types';
|
||||
import { MarketState } from '@ghostfolio/common/types';
|
||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
|
||||
export interface Position {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
averagePrice: number;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
|
@ -7,10 +7,12 @@ import {
|
||||
EventEmitter,
|
||||
HostBinding,
|
||||
Input,
|
||||
OnChanges,
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { Position } from '@ghostfolio/common/interfaces';
|
||||
import { Params } from '@angular/router';
|
||||
import { ISearchResultItem } from '@ghostfolio/ui/assistant/interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@ -18,22 +20,46 @@ import { Position } from '@ghostfolio/common/interfaces';
|
||||
templateUrl: './assistant-list-item.html',
|
||||
styleUrls: ['./assistant-list-item.scss']
|
||||
})
|
||||
export class AssistantListItemComponent implements FocusableOption {
|
||||
export class AssistantListItemComponent implements FocusableOption, OnChanges {
|
||||
@HostBinding('attr.tabindex') tabindex = -1;
|
||||
@HostBinding('class.has-focus') get getHasFocus() {
|
||||
return this.hasFocus;
|
||||
}
|
||||
|
||||
@Input() holding: Position;
|
||||
@Input() item: ISearchResultItem;
|
||||
@Input() mode: 'assetProfile' | 'holding';
|
||||
|
||||
@Output() clicked = new EventEmitter<void>();
|
||||
|
||||
@ViewChild('link') public linkElement: ElementRef;
|
||||
|
||||
public hasFocus = false;
|
||||
public queryParams: Params;
|
||||
public routerLink: string[];
|
||||
|
||||
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() {
|
||||
this.hasFocus = true;
|
||||
|
||||
|
@ -1,12 +1,16 @@
|
||||
<a
|
||||
#link
|
||||
class="d-block px-2 py-1 text-truncate"
|
||||
[queryParams]="{
|
||||
dataSource: holding?.dataSource,
|
||||
positionDetailDialog: true,
|
||||
symbol: holding?.symbol
|
||||
}"
|
||||
[routerLink]="['/portfolio', 'holdings']"
|
||||
class="d-block line-height-1 px-2 py-1 text-truncate"
|
||||
[queryParams]="queryParams"
|
||||
[routerLink]="routerLink"
|
||||
(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 { NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
|
||||
|
||||
import { AssistantListItemComponent } from './assistant-list-item.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [AssistantListItemComponent],
|
||||
exports: [AssistantListItemComponent],
|
||||
imports: [CommonModule, RouterModule]
|
||||
imports: [CommonModule, GfSymbolModule, RouterModule]
|
||||
})
|
||||
export class GfAssistantListItemModule {}
|
||||
|
@ -16,9 +16,10 @@ import {
|
||||
} from '@angular/core';
|
||||
import { FormControl } from '@angular/forms';
|
||||
import { MatMenuTrigger } from '@angular/material/menu';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { Position } from '@ghostfolio/common/interfaces';
|
||||
import { EMPTY, Subject, lastValueFrom } from 'rxjs';
|
||||
import { translate } from '@ghostfolio/ui/i18n';
|
||||
import { EMPTY, Observable, Subject, lastValueFrom } from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
debounceTime,
|
||||
@ -29,13 +30,13 @@ import {
|
||||
} from 'rxjs/operators';
|
||||
|
||||
import { AssistantListItemComponent } from './assistant-list-item/assistant-list-item.component';
|
||||
import { ISearchResults } from './interfaces/interfaces';
|
||||
import { ISearchResultItem, ISearchResults } from './interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
selector: 'gf-assistant',
|
||||
templateUrl: './assistant.html',
|
||||
styleUrls: ['./assistant.scss']
|
||||
styleUrls: ['./assistant.scss'],
|
||||
templateUrl: './assistant.html'
|
||||
})
|
||||
export class AssistantComponent implements OnDestroy, OnInit {
|
||||
@HostListener('document:keydown', ['$event']) onKeydown(
|
||||
@ -71,6 +72,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
@Input() deviceType: string;
|
||||
@Input() hasPermissionToAccessAdminControl: boolean;
|
||||
|
||||
@Output() closed = new EventEmitter<void>();
|
||||
|
||||
@ -87,6 +89,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
|
||||
public placeholder = $localize`Find holding...`;
|
||||
public searchFormControl = new FormControl('');
|
||||
public searchResults: ISearchResults = {
|
||||
assetProfiles: [],
|
||||
holdings: []
|
||||
};
|
||||
|
||||
@ -94,6 +97,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private adminService: AdminService,
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService
|
||||
) {}
|
||||
@ -104,6 +108,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
|
||||
map((searchTerm) => {
|
||||
this.isLoading = true;
|
||||
this.searchResults = {
|
||||
assetProfiles: [],
|
||||
holdings: []
|
||||
};
|
||||
|
||||
@ -115,6 +120,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
|
||||
distinctUntilChanged(),
|
||||
mergeMap(async (searchTerm) => {
|
||||
const result = <ISearchResults>{
|
||||
assetProfiles: [],
|
||||
holdings: []
|
||||
};
|
||||
|
||||
@ -140,6 +146,7 @@ export class AssistantComponent implements OnDestroy, OnInit {
|
||||
this.isLoading = true;
|
||||
this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap();
|
||||
this.searchResults = {
|
||||
assetProfiles: [],
|
||||
holdings: []
|
||||
};
|
||||
|
||||
@ -180,10 +187,23 @@ export class AssistantComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private async getSearchResults(aSearchTerm: string) {
|
||||
let holdings: Position[] = [];
|
||||
let assetProfiles: ISearchResultItem[] = [];
|
||||
let holdings: ISearchResultItem[] = [];
|
||||
|
||||
if (this.hasPermissionToAccessAdminControl) {
|
||||
try {
|
||||
assetProfiles = await lastValueFrom(
|
||||
this.searchAssetProfiles(aSearchTerm)
|
||||
);
|
||||
assetProfiles = assetProfiles.slice(
|
||||
0,
|
||||
AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
try {
|
||||
holdings = await lastValueFrom(this.searchHolding(aSearchTerm));
|
||||
holdings = await lastValueFrom(this.searchHoldings(aSearchTerm));
|
||||
holdings = holdings.slice(
|
||||
0,
|
||||
AssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT
|
||||
@ -191,11 +211,46 @@ export class AssistantComponent implements OnDestroy, OnInit {
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
assetProfiles,
|
||||
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
|
||||
.fetchPositions({
|
||||
filters: [
|
||||
@ -211,7 +266,17 @@ export class AssistantComponent implements OnDestroy, OnInit {
|
||||
return EMPTY;
|
||||
}),
|
||||
map(({ positions }) => {
|
||||
return positions;
|
||||
return positions.map(
|
||||
({ assetSubClass, currency, dataSource, name, symbol }) => {
|
||||
return {
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
symbol,
|
||||
assetSubClassString: translate(assetSubClass)
|
||||
};
|
||||
}
|
||||
);
|
||||
}),
|
||||
takeUntil(this.unsubscribeSubject)
|
||||
);
|
||||
|
@ -45,8 +45,9 @@
|
||||
<div>
|
||||
<div class="h6 mb-1 px-2" i18n>Holdings</div>
|
||||
<gf-assistant-list-item
|
||||
*ngFor="let holding of searchResults?.holdings"
|
||||
[holding]="holding"
|
||||
*ngFor="let searchResultItem of searchResults?.holdings"
|
||||
mode="holding"
|
||||
[item]="searchResultItem"
|
||||
(clicked)="onCloseAssistant()"
|
||||
/>
|
||||
<ng-container *ngIf="searchResults?.holdings?.length === 0">
|
||||
@ -62,5 +63,26 @@
|
||||
<div *ngIf="!isLoading" class="px-2 py-1" i18n>No entries...</div>
|
||||
</ng-container>
|
||||
</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>
|
||||
|
@ -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 {
|
||||
holdings: Position[];
|
||||
assetProfiles: ISearchResultItem[];
|
||||
holdings: ISearchResultItem[];
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user