feat: enrich ledger events with logo metadata
This commit is contained in:
@@ -86,6 +86,9 @@ export class BrokerageOperationDto {
|
|||||||
@ApiProperty({ type: String, nullable: true })
|
@ApiProperty({ type: String, nullable: true })
|
||||||
instrumentType: string | null;
|
instrumentType: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true })
|
||||||
|
logoName: string | null;
|
||||||
|
|
||||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||||
payment: MoneyDto | null;
|
payment: MoneyDto | null;
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export function mapBrokerageOperation(
|
|||||||
instrumentUid: text(operation.instrumentUid),
|
instrumentUid: text(operation.instrumentUid),
|
||||||
ticker: text(operation.ticker),
|
ticker: text(operation.ticker),
|
||||||
instrumentType: text(operation.instrumentType),
|
instrumentType: text(operation.instrumentType),
|
||||||
|
logoName: null,
|
||||||
payment: money(operation.payment),
|
payment: money(operation.payment),
|
||||||
quantity:
|
quantity:
|
||||||
operation.quantity === undefined ? null : String(operation.quantity),
|
operation.quantity === undefined ? null : String(operation.quantity),
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ describe('BrokerageOperationsService', () => {
|
|||||||
getOperationsByCursor: jest.fn(),
|
getOperationsByCursor: jest.fn(),
|
||||||
};
|
};
|
||||||
const requestContext = { set: jest.fn() };
|
const requestContext = { set: jest.fn() };
|
||||||
|
const metadata = { resolve: jest.fn() };
|
||||||
const service = new BrokerageOperationsService(
|
const service = new BrokerageOperationsService(
|
||||||
access as never,
|
access as never,
|
||||||
tbank as never,
|
tbank as never,
|
||||||
requestContext as never,
|
requestContext as never,
|
||||||
|
metadata as never,
|
||||||
);
|
);
|
||||||
let operationsRequest: unknown;
|
let operationsRequest: unknown;
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ describe('BrokerageOperationsService', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
access.withAccount.mockReset();
|
access.withAccount.mockReset();
|
||||||
tbank.getOperationsByCursor.mockReset();
|
tbank.getOperationsByCursor.mockReset();
|
||||||
|
metadata.resolve.mockReset();
|
||||||
access.withAccount.mockImplementation(
|
access.withAccount.mockImplementation(
|
||||||
(
|
(
|
||||||
_userId: string,
|
_userId: string,
|
||||||
@@ -103,6 +106,94 @@ describe('BrokerageOperationsService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes resolved instrument logo metadata in a ledger entry', async () => {
|
||||||
|
tbank.getOperationsByCursor.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'coupon',
|
||||||
|
date: { seconds: '1' },
|
||||||
|
type: 'OPERATION_TYPE_COUPON',
|
||||||
|
instrumentUid: 'instrument-uid',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
metadata.resolve.mockResolvedValue({
|
||||||
|
brand: { logoName: 'instrument-logo.png' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
|
||||||
|
items: [{ id: 'coupon', logoName: 'instrument-logo.png' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves repeated instrument metadata only once per ledger page', async () => {
|
||||||
|
tbank.getOperationsByCursor.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'first',
|
||||||
|
date: { seconds: '1' },
|
||||||
|
type: 'OPERATION_TYPE_COUPON',
|
||||||
|
instrumentUid: 'instrument-uid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'second',
|
||||||
|
date: { seconds: '2' },
|
||||||
|
type: 'OPERATION_TYPE_COUPON',
|
||||||
|
instrumentUid: 'instrument-uid',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
metadata.resolve.mockResolvedValue({
|
||||||
|
brand: { logoName: 'instrument-logo.png' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.get('user', 'account', {});
|
||||||
|
|
||||||
|
expect(metadata.resolve).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a UID-only lookup suppress a later FIGI fallback on the same page', async () => {
|
||||||
|
tbank.getOperationsByCursor.mockResolvedValue({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'first',
|
||||||
|
date: { seconds: '1' },
|
||||||
|
type: 'OPERATION_TYPE_COUPON',
|
||||||
|
instrumentUid: 'instrument-uid',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'second',
|
||||||
|
date: { seconds: '2' },
|
||||||
|
type: 'OPERATION_TYPE_COUPON',
|
||||||
|
instrumentUid: 'instrument-uid',
|
||||||
|
figi: 'BBG000000001',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
metadata.resolve.mockResolvedValueOnce(null).mockResolvedValueOnce({
|
||||||
|
brand: { logoName: 'fallback-logo.png' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.get('user', 'account', {})).resolves.toMatchObject({
|
||||||
|
items: [
|
||||||
|
{ id: 'first', logoName: null },
|
||||||
|
{ id: 'second', logoName: 'fallback-logo.png' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(metadata.resolve).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
'token',
|
||||||
|
'instrument-uid',
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(metadata.resolve).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
'token',
|
||||||
|
'instrument-uid',
|
||||||
|
'BBG000000001',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('returns an empty page without calling T-Bank when the period starts in the future', async () => {
|
it('returns an empty page without calling T-Bank when the period starts in the future', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }),
|
service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
mapBrokerageOperation,
|
mapBrokerageOperation,
|
||||||
operationCategory,
|
operationCategory,
|
||||||
} from './brokerage-operation.mapper';
|
} from './brokerage-operation.mapper';
|
||||||
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ export class BrokerageOperationsService {
|
|||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly requestContext: RequestContextService,
|
private readonly requestContext: RequestContextService,
|
||||||
|
private readonly metadata: InstrumentMetadataService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async get(
|
async get(
|
||||||
@@ -56,15 +58,25 @@ export class BrokerageOperationsService {
|
|||||||
limit: query.limit ?? 100,
|
limit: query.limit ?? 100,
|
||||||
state: 'OPERATION_STATE_EXECUTED',
|
state: 'OPERATION_STATE_EXECUTED',
|
||||||
});
|
});
|
||||||
|
const items = (response.items ?? []).filter(
|
||||||
|
(item) =>
|
||||||
|
!query.categories?.length ||
|
||||||
|
query.categories.includes(operationCategory(item.type)),
|
||||||
|
);
|
||||||
|
const logoNames = new Map<string, Promise<string | null>>();
|
||||||
const page = {
|
const page = {
|
||||||
accountId,
|
accountId,
|
||||||
items: (response.items ?? [])
|
items: await Promise.all(
|
||||||
.filter(
|
items.map(async (item) => ({
|
||||||
(item) =>
|
...mapBrokerageOperation(item),
|
||||||
!query.categories?.length ||
|
logoName: await this.logoName(
|
||||||
query.categories.includes(operationCategory(item.type)),
|
token,
|
||||||
)
|
item.instrumentUid,
|
||||||
.map(mapBrokerageOperation),
|
item.figi,
|
||||||
|
logoNames,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
),
|
||||||
nextCursor: response.nextCursor || null,
|
nextCursor: response.nextCursor || null,
|
||||||
hasNext: Boolean(response.hasNext),
|
hasNext: Boolean(response.hasNext),
|
||||||
observedAt: new Date().toISOString(),
|
observedAt: new Date().toISOString(),
|
||||||
@@ -83,6 +95,28 @@ export class BrokerageOperationsService {
|
|||||||
to: dateBoundary(query.to, true) ?? now,
|
to: dateBoundary(query.to, true) ?? now,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async logoName(
|
||||||
|
token: string,
|
||||||
|
instrumentUid: string | undefined,
|
||||||
|
figi: string | undefined,
|
||||||
|
logoNames: Map<string, Promise<string | null>>,
|
||||||
|
): Promise<string | null> {
|
||||||
|
const uid = instrumentUid?.trim() || null;
|
||||||
|
const instrumentFigi = figi?.trim() || null;
|
||||||
|
const key =
|
||||||
|
uid || instrumentFigi
|
||||||
|
? JSON.stringify({ uid, figi: instrumentFigi })
|
||||||
|
: null;
|
||||||
|
if (!key) return Promise.resolve(null);
|
||||||
|
const existing = logoNames.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const logoName = this.metadata
|
||||||
|
.resolve(token, uid, instrumentFigi)
|
||||||
|
.then((instrument) => instrument?.brand?.logoName?.trim() || null);
|
||||||
|
logoNames.set(key, logoName);
|
||||||
|
return logoName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheKey(
|
function cacheKey(
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
|
|
||||||
|
describe('InstrumentMetadataService', () => {
|
||||||
|
const tbank = {
|
||||||
|
getInstrumentByFigi: jest.fn(),
|
||||||
|
getInstrumentByUid: jest.fn(),
|
||||||
|
};
|
||||||
|
const logger = { event: jest.fn() };
|
||||||
|
let service: InstrumentMetadataService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
tbank.getInstrumentByFigi.mockReset();
|
||||||
|
tbank.getInstrumentByUid.mockReset();
|
||||||
|
service = new InstrumentMetadataService(tbank as never, logger as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers the instrument UID when metadata is available', async () => {
|
||||||
|
tbank.getInstrumentByUid.mockResolvedValue({
|
||||||
|
name: 'Инструмент',
|
||||||
|
brand: { logoName: 'instrument.png' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.resolve('token', 'uid-1', 'figi-1'),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
brand: { logoName: 'instrument.png' },
|
||||||
|
});
|
||||||
|
expect(tbank.getInstrumentByFigi).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to FIGI when the UID has no matching instrument', async () => {
|
||||||
|
tbank.getInstrumentByUid.mockResolvedValue(null);
|
||||||
|
tbank.getInstrumentByFigi.mockResolvedValue({
|
||||||
|
ticker: 'SBER',
|
||||||
|
brand: { logoName: 'sber.png' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.resolve('token', 'uid-1', 'figi-1'),
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
ticker: 'SBER',
|
||||||
|
});
|
||||||
|
expect(tbank.getInstrumentByFigi).toHaveBeenCalledWith('token', 'figi-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a nullable fallback when instrument metadata is unavailable', async () => {
|
||||||
|
tbank.getInstrumentByUid.mockRejectedValue(new Error('unavailable'));
|
||||||
|
|
||||||
|
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caches negative lookups and avoids repeated upstream requests', async () => {
|
||||||
|
tbank.getInstrumentByUid.mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||||
|
await expect(service.resolve('token', 'uid-1', null)).resolves.toBeNull();
|
||||||
|
|
||||||
|
expect(tbank.getInstrumentByUid).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { LRUCache } from 'lru-cache';
|
||||||
|
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
||||||
|
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||||
|
|
||||||
|
const metadataForMs = 86_400_000;
|
||||||
|
const maxCachedInstruments = 500;
|
||||||
|
const missingInstrument = Symbol('missingInstrument');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class InstrumentMetadataService {
|
||||||
|
private readonly metadata = new LRUCache<
|
||||||
|
string,
|
||||||
|
TbankInstrument | typeof missingInstrument
|
||||||
|
>({
|
||||||
|
max: maxCachedInstruments,
|
||||||
|
ttl: metadataForMs,
|
||||||
|
});
|
||||||
|
private readonly pending = new Map<string, Promise<TbankInstrument | null>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async resolve(
|
||||||
|
token: string,
|
||||||
|
instrumentUid: string | null | undefined,
|
||||||
|
figi: string | null | undefined,
|
||||||
|
): Promise<TbankInstrument | null> {
|
||||||
|
const uid = instrumentUid?.trim();
|
||||||
|
if (uid) {
|
||||||
|
const byUid = await this.lookup(`uid:${uid}`, () =>
|
||||||
|
this.tbank.getInstrumentByUid(token, uid),
|
||||||
|
);
|
||||||
|
if (byUid) return byUid;
|
||||||
|
}
|
||||||
|
const instrumentFigi = figi?.trim();
|
||||||
|
return instrumentFigi
|
||||||
|
? this.lookup(`figi:${instrumentFigi}`, () =>
|
||||||
|
this.tbank.getInstrumentByFigi(token, instrumentFigi),
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookup(
|
||||||
|
key: string,
|
||||||
|
fetch: () => Promise<TbankInstrument | null>,
|
||||||
|
): Promise<TbankInstrument | null> {
|
||||||
|
const cached = this.metadata.get(key);
|
||||||
|
if (cached !== undefined)
|
||||||
|
return cached === missingInstrument ? null : cached;
|
||||||
|
const pending = this.pending.get(key);
|
||||||
|
if (pending) return pending;
|
||||||
|
const request = this.fetch(key, fetch);
|
||||||
|
this.pending.set(key, request);
|
||||||
|
try {
|
||||||
|
return await request;
|
||||||
|
} finally {
|
||||||
|
this.pending.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch(
|
||||||
|
key: string,
|
||||||
|
request: () => Promise<TbankInstrument | null>,
|
||||||
|
): Promise<TbankInstrument | null> {
|
||||||
|
try {
|
||||||
|
const value = await request();
|
||||||
|
this.metadata.set(key, value ?? missingInstrument);
|
||||||
|
this.logger.event('debug', 'instrument-metadata.resolved', {
|
||||||
|
found: Boolean(value?.name?.trim() || value?.ticker?.trim()),
|
||||||
|
});
|
||||||
|
return value;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||||
|
code:
|
||||||
|
error instanceof Error && 'response' in error
|
||||||
|
? ((error as { response?: { code?: string } }).response?.code ??
|
||||||
|
'UNKNOWN')
|
||||||
|
: 'UNKNOWN',
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ jest.mock('./tbank-access.service', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
import type { TbankAccountContext } from './tbank-access.service';
|
import type { TbankAccountContext } from './tbank-access.service';
|
||||||
|
|
||||||
describe('PortfolioService', () => {
|
describe('PortfolioService', () => {
|
||||||
@@ -20,11 +21,15 @@ describe('PortfolioService', () => {
|
|||||||
};
|
};
|
||||||
const requestContext = { set: jest.fn() };
|
const requestContext = { set: jest.fn() };
|
||||||
const logger = { event: jest.fn() };
|
const logger = { event: jest.fn() };
|
||||||
|
const metadata = new InstrumentMetadataService(
|
||||||
|
tbank as never,
|
||||||
|
logger as never,
|
||||||
|
);
|
||||||
const service = new PortfolioService(
|
const service = new PortfolioService(
|
||||||
access as never,
|
access as never,
|
||||||
tbank as never,
|
tbank as never,
|
||||||
requestContext as never,
|
requestContext as never,
|
||||||
logger as never,
|
metadata,
|
||||||
);
|
);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { RequestContextService } from '../common/logging/request-context.service';
|
import { RequestContextService } from '../common/logging/request-context.service';
|
||||||
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
decimal,
|
decimal,
|
||||||
@@ -11,11 +10,11 @@ import {
|
|||||||
} from './decimal';
|
} from './decimal';
|
||||||
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
||||||
import { TbankAccessService } from './tbank-access.service';
|
import { TbankAccessService } from './tbank-access.service';
|
||||||
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
import { TbankClient, TbankMoney } from './tbank.client';
|
import { TbankClient, TbankMoney } from './tbank.client';
|
||||||
|
|
||||||
const freshForMs = 30_000;
|
const freshForMs = 30_000;
|
||||||
const fallbackForMs = 300_000;
|
const fallbackForMs = 300_000;
|
||||||
const metadataForMs = 86_400_000;
|
|
||||||
const types: Record<string, string> = {
|
const types: Record<string, string> = {
|
||||||
share: 'STOCK',
|
share: 'STOCK',
|
||||||
bond: 'BOND',
|
bond: 'BOND',
|
||||||
@@ -28,26 +27,16 @@ const types: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type CachedSnapshot = { snapshot: PortfolioDto; storedAt: number };
|
type CachedSnapshot = { snapshot: PortfolioDto; storedAt: number };
|
||||||
type CachedMetadata = {
|
|
||||||
value: {
|
|
||||||
ticker?: string;
|
|
||||||
name?: string;
|
|
||||||
isin?: string;
|
|
||||||
brand?: { logoName?: string | null };
|
|
||||||
} | null;
|
|
||||||
storedAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
private readonly snapshots = new Map<string, CachedSnapshot>();
|
private readonly snapshots = new Map<string, CachedSnapshot>();
|
||||||
private readonly metadata = new Map<string, CachedMetadata>();
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly requestContext: RequestContextService,
|
private readonly requestContext: RequestContextService,
|
||||||
private readonly logger: StructuredLogger,
|
private readonly metadata: InstrumentMetadataService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
||||||
@@ -168,7 +157,7 @@ export class PortfolioService {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const metadata = await this.instrument(token, instrumentUid, figi);
|
const metadata = await this.metadata.resolve(token, instrumentUid, figi);
|
||||||
return {
|
return {
|
||||||
figi,
|
figi,
|
||||||
displayName:
|
displayName:
|
||||||
@@ -202,53 +191,6 @@ export class PortfolioService {
|
|||||||
? { currency, value: formatDecimal(decimal(value)) }
|
? { currency, value: formatDecimal(decimal(value)) }
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async instrument(
|
|
||||||
token: string,
|
|
||||||
instrumentUid: string | null,
|
|
||||||
figi: string | null,
|
|
||||||
) {
|
|
||||||
const lookup = async (
|
|
||||||
key: string,
|
|
||||||
fetch: () => Promise<{
|
|
||||||
ticker?: string;
|
|
||||||
name?: string;
|
|
||||||
isin?: string;
|
|
||||||
brand?: { logoName?: string | null };
|
|
||||||
} | null>,
|
|
||||||
) => {
|
|
||||||
const cached = this.metadata.get(key);
|
|
||||||
if (cached && Date.now() - cached.storedAt <= metadataForMs)
|
|
||||||
return cached.value;
|
|
||||||
try {
|
|
||||||
const value = await fetch();
|
|
||||||
this.metadata.set(key, { value, storedAt: Date.now() });
|
|
||||||
this.logger.event('debug', 'portfolio.instrument-metadata.resolved', {
|
|
||||||
found: Boolean(value?.name?.trim() || value?.ticker?.trim()),
|
|
||||||
});
|
|
||||||
return value;
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.event('warn', 'portfolio.instrument-metadata.failed', {
|
|
||||||
code:
|
|
||||||
error instanceof Error && 'response' in error
|
|
||||||
? ((error as { response?: { code?: string } }).response?.code ??
|
|
||||||
'UNKNOWN')
|
|
||||||
: 'UNKNOWN',
|
|
||||||
});
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if (instrumentUid) {
|
|
||||||
const byUid = await lookup(`uid:${instrumentUid}`, () =>
|
|
||||||
this.tbank.getInstrumentByUid(token, instrumentUid),
|
|
||||||
);
|
|
||||||
if (byUid) return byUid;
|
|
||||||
}
|
|
||||||
if (!figi) return null;
|
|
||||||
return lookup(`figi:${figi}`, () =>
|
|
||||||
this.tbank.getInstrumentByFigi(token, figi),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function decimalFromString(value: string) {
|
function decimalFromString(value: string) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
|||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
import { BrokerageOperationsController } from './brokerage-operations.controller';
|
import { BrokerageOperationsController } from './brokerage-operations.controller';
|
||||||
import { BrokerageOperationsService } from './brokerage-operations.service';
|
import { BrokerageOperationsService } from './brokerage-operations.service';
|
||||||
|
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||||
import { MoexClient } from './moex.client';
|
import { MoexClient } from './moex.client';
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
@@ -27,6 +28,7 @@ import { UpcomingEventsService } from './upcoming-events.service';
|
|||||||
TbankConnectionService,
|
TbankConnectionService,
|
||||||
BrokerageAccountsService,
|
BrokerageAccountsService,
|
||||||
BrokerageOperationsService,
|
BrokerageOperationsService,
|
||||||
|
InstrumentMetadataService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
MoexClient,
|
MoexClient,
|
||||||
UpcomingEventsService,
|
UpcomingEventsService,
|
||||||
|
|||||||
Reference in New Issue
Block a user