feat: enrich ledger events with logo metadata

This commit is contained in:
2026-08-26 15:03:48 +03:00
parent f1df104462
commit 84fabd264f
9 changed files with 294 additions and 69 deletions
+3
View File
@@ -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;
+1
View File
@@ -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' }),
+41 -7
View File
@@ -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);
});
});
+86
View File
@@ -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;
}
}
}
+6 -1
View File
@@ -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(() => {
+3 -61
View File
@@ -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) {
+2
View File
@@ -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,