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. 43
      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 };
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) {
where.AND = [];
@ -363,10 +381,6 @@ export class OrderService {
return type === 'SYMBOL';
})?.id;
const searchQuery = filters?.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
if (filtersByAccount?.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
@ -380,7 +394,7 @@ export class OrderService {
}
if (filtersByAssetClass?.length > 0) {
where.SymbolProfile = {
symbolProfileConditions.push({
OR: [
{
AND: [
@ -405,7 +419,7 @@ export class OrderService {
}
}
]
};
});
}
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
@ -18,6 +19,7 @@ import {
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import {
LookupItem,
PortfolioDetails,
PortfolioDividends,
PortfolioHoldingResponse,
@ -427,6 +429,28 @@ export class PortfolioController {
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')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor)

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

@ -34,13 +34,19 @@ import {
EMERGENCY_FUND_TAG_ID,
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
getSum,
parseDate
} from '@ghostfolio/common/helper';
import {
AccountsResponse,
EnhancedSymbolProfile,
Filter,
HistoricalDataItem,
InvestmentItem,
LookupItem,
PortfolioDetails,
PortfolioHoldingResponse,
PortfolioInvestments,
@ -85,7 +91,7 @@ import {
parseISO,
set
} from 'date-fns';
import { isEmpty } from 'lodash';
import { isEmpty, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
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({
dateRange = 'max',
filters,

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

@ -34,6 +34,7 @@ import {
Filter,
ImportResponse,
InfoItem,
LookupItem,
LookupResponse,
MarketDataDetailsResponse,
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({
filters,
range

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

@ -11,7 +11,9 @@
[displayWith]="displayFn"
(optionSelected)="onUpdateSymbol($event)"
>
@if (!isLoading) {
@if (
(!isLoadingRemote || !isLoadingLocal) && filteredLookupItems.length > 0
) {
@for (lookupItem of filteredLookupItems; track lookupItem) {
<mat-option
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>
@if (isLoading) {
@if (isLoadingRemote || isLoadingLocal) {
<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);
}
}
.mat-more-symbols-progress-spinner {
margin-right: 0.5rem;
}

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

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

Loading…
Cancel
Save