Browse Source

Allow for symbol search by asset type

Prototype for quicker symbol automcomplete using local data.
pull/3507/head
Usiel Riedl 7 months ago
parent
commit
ecff7514e7
  1. 26
      apps/api/src/app/order/order.service.ts
  2. 24
      apps/api/src/app/portfolio/portfolio.controller.ts
  3. 49
      apps/api/src/app/portfolio/portfolio.service.ts
  4. 15
      apps/client/src/app/services/data.service.ts
  5. 17
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html
  6. 4
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss
  7. 49
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

26
apps/api/src/app/order/order.service.ts

@ -335,6 +335,24 @@ export class OrderService {
]; ];
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
let symbolProfileConditions: Prisma.SymbolProfileWhereInput[] = [];
where.SymbolProfile = {
AND: symbolProfileConditions
};
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (searchQuery) {
symbolProfileConditions.push({
OR: [
{ id: { mode: 'insensitive', contains: searchQuery } },
{ isin: { mode: 'insensitive', contains: searchQuery } },
{ name: { mode: 'insensitive', contains: searchQuery } },
{ symbol: { mode: 'insensitive', contains: searchQuery } }
]
});
}
if (endDate || startDate) { if (endDate || startDate) {
where.AND = []; where.AND = [];
@ -363,10 +381,6 @@ export class OrderService {
return type === 'SYMBOL'; return type === 'SYMBOL';
})?.id; })?.id;
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) { if (filtersByAccount?.length > 0) {
where.accountId = { where.accountId = {
in: filtersByAccount.map(({ id }) => { in: filtersByAccount.map(({ id }) => {
@ -380,7 +394,7 @@ export class OrderService {
} }
if (filtersByAssetClass?.length > 0) { if (filtersByAssetClass?.length > 0) {
where.SymbolProfile = { symbolProfileConditions.push({
OR: [ OR: [
{ {
AND: [ AND: [
@ -405,7 +419,7 @@ export class OrderService {
} }
} }
] ]
}; });
} }
if (filterByDataSource && filterBySymbol) { if (filterByDataSource && filterBySymbol) {

24
apps/api/src/app/portfolio/portfolio.controller.ts

@ -1,3 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
@ -18,6 +19,7 @@ import {
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
LookupItem,
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
PortfolioHoldingResponse, PortfolioHoldingResponse,
@ -427,6 +429,28 @@ export class PortfolioController {
return { holdings: Object.values(holdings) }; return { holdings: Object.values(holdings) };
} }
@Get('lookup')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('query') filterBySearchQuery?: string
): Promise<{ items: LookupItem[] }> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterBySearchQuery
});
return {
items: await this.portfolioService.getLookup({
filters,
impersonationId,
userId: this.request.user.id
})
};
}
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)

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

@ -34,13 +34,19 @@ import {
EMERGENCY_FUND_TAG_ID, EMERGENCY_FUND_TAG_ID,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
getAssetProfileIdentifier,
getSum,
parseDate
} from '@ghostfolio/common/helper';
import { import {
AccountsResponse, AccountsResponse,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter,
HistoricalDataItem, HistoricalDataItem,
InvestmentItem, InvestmentItem,
LookupItem,
PortfolioDetails, PortfolioDetails,
PortfolioHoldingResponse, PortfolioHoldingResponse,
PortfolioInvestments, PortfolioInvestments,
@ -85,7 +91,7 @@ import {
parseISO, parseISO,
set set
} from 'date-fns'; } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
@ -342,6 +348,45 @@ export class PortfolioService {
}; };
} }
public async getLookup({
filters,
impersonationId,
userId
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
userId: string;
}): Promise<LookupItem[]> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
const { activities } = await this.orderService.getOrders({
filters,
userCurrency,
userId
});
return uniqBy(activities, (activity) => {
return getAssetProfileIdentifier({
dataSource: activity.SymbolProfile.dataSource,
symbol: activity.SymbolProfile.symbol
});
}).map((activity) => ({
assetClass: activity.SymbolProfile.assetClass,
assetSubClass: activity.SymbolProfile.assetSubClass,
currency: activity.SymbolProfile.currency,
dataProviderInfo: {
isPremium: false,
name: 'Ghostfolio API'
},
dataSource: activity.SymbolProfile.dataSource,
name: activity.SymbolProfile.name,
symbol: activity.SymbolProfile.symbol
}));
}
public async getDetails({ public async getDetails({
dateRange = 'max', dateRange = 'max',
filters, filters,

15
apps/client/src/app/services/data.service.ts

@ -34,6 +34,7 @@ import {
Filter, Filter,
ImportResponse, ImportResponse,
InfoItem, InfoItem,
LookupItem,
LookupResponse, LookupResponse,
MarketDataDetailsResponse, MarketDataDetailsResponse,
OAuthResponse, OAuthResponse,
@ -565,6 +566,20 @@ export class DataService {
); );
} }
public fetchPortfolioLookup({ query }: { query: string }) {
let params = new HttpParams().set('query', query);
return this.http
.get<{ items: LookupItem[] }>('/api/v1/portfolio/lookup', {
params
})
.pipe(
map((response) => {
return response.items;
})
);
}
public fetchPortfolioHoldings({ public fetchPortfolioHoldings({
filters, filters,
range range

17
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html

@ -11,7 +11,9 @@
[displayWith]="displayFn" [displayWith]="displayFn"
(optionSelected)="onUpdateSymbol($event)" (optionSelected)="onUpdateSymbol($event)"
> >
@if (!isLoading) { @if (
(!isLoadingRemote || !isLoadingLocal) && filteredLookupItems.length > 0
) {
@for (lookupItem of filteredLookupItems; track lookupItem) { @for (lookupItem of filteredLookupItems; track lookupItem) {
<mat-option <mat-option
class="line-height-1" class="line-height-1"
@ -41,9 +43,20 @@
> >
} }
} }
@if (isLoadingRemote || isLoadingLocal) {
<mat-option class="line-height-1" [disabled]="true">
<span class="align-items-center d-flex line-height-1">
<mat-spinner
class="mat-more-symbols-progress-spinner"
[diameter]="20"
/>
Loading more symbols ...
</span>
</mat-option>
}
} }
</mat-autocomplete> </mat-autocomplete>
@if (isLoading) { @if (isLoadingRemote || isLoadingLocal) {
<mat-spinner class="position-absolute" [diameter]="20" /> <mat-spinner class="position-absolute" [diameter]="20" />
} }

4
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss

@ -6,3 +6,7 @@
top: calc(50% - 10px); top: calc(50% - 10px);
} }
} }
.mat-more-symbols-progress-spinner {
margin-right: 0.5rem;
}

49
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts

@ -1,3 +1,4 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { LookupItem } from '@ghostfolio/common/interfaces'; import { LookupItem } from '@ghostfolio/common/interfaces';
@ -34,11 +35,12 @@ import {
import { MatInput, MatInputModule } from '@angular/material/input'; import { MatInput, MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { Subject, tap } from 'rxjs'; import { combineLatest, Subject, tap } from 'rxjs';
import { import {
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged,
filter, filter,
startWith,
switchMap, switchMap,
takeUntil takeUntil
} from 'rxjs/operators'; } from 'rxjs/operators';
@ -77,7 +79,8 @@ export class GfSymbolAutocompleteComponent
implements OnInit, OnDestroy implements OnInit, OnDestroy
{ {
@Input() private includeIndices = false; @Input() private includeIndices = false;
@Input() public isLoading = false; @Input() public isLoadingLocal = false;
@Input() public isLoadingRemote = false;
@ViewChild(MatInput) private input: MatInput; @ViewChild(MatInput) private input: MatInput;
@ -120,29 +123,53 @@ export class GfSymbolAutocompleteComponent
return isString(query) && query.length > 1; return isString(query) && query.length > 1;
}), }),
tap(() => { tap(() => {
this.isLoading = true; this.isLoadingRemote = true;
this.isLoadingLocal = true;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}), }),
debounceTime(400), debounceTime(400),
distinctUntilChanged(), distinctUntilChanged(),
takeUntil(this.unsubscribeSubject), takeUntil(this.unsubscribeSubject),
switchMap((query: string) => { switchMap((query: string) =>
return this.dataService.fetchSymbols({ combineLatest([
query, this.dataService
includeIndices: this.includeIndices .fetchPortfolioLookup({
}); query
}) })
.pipe(startWith(undefined)),
this.dataService
.fetchSymbols({
query,
includeIndices: this.includeIndices
})
.pipe(startWith(undefined))
])
)
) )
.subscribe((filteredLookupItems) => { .subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems.map((lookupItem) => { const [localItems, remoteItems]: [LookupItem[], LookupItem[]] =
filteredLookupItems;
const uniqueItems = [
...new Map(
(localItems ?? [])
.concat(remoteItems ?? [])
.map((item) => [item.symbol, item])
).values()
];
this.filteredLookupItems = uniqueItems.map((lookupItem) => {
return { return {
...lookupItem, ...lookupItem,
assetSubClassString: translate(lookupItem.assetSubClass) assetSubClassString: translate(lookupItem.assetSubClass)
}; };
}); });
this.isLoading = false; if (localItems !== undefined) {
this.isLoadingLocal = false;
}
if (remoteItems !== undefined) {
this.isLoadingRemote = false;
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

Loading…
Cancel
Save