feat: enrich ledger events with logo metadata
This commit is contained in:
@@ -86,6 +86,9 @@ export class BrokerageOperationDto {
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
instrumentType: string | null;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true })
|
||||
logoName: string | null;
|
||||
|
||||
@ApiProperty({ type: MoneyDto, nullable: true })
|
||||
payment: MoneyDto | null;
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ export function mapBrokerageOperation(
|
||||
instrumentUid: text(operation.instrumentUid),
|
||||
ticker: text(operation.ticker),
|
||||
instrumentType: text(operation.instrumentType),
|
||||
logoName: null,
|
||||
payment: money(operation.payment),
|
||||
quantity:
|
||||
operation.quantity === undefined ? null : String(operation.quantity),
|
||||
|
||||
@@ -17,10 +17,12 @@ describe('BrokerageOperationsService', () => {
|
||||
getOperationsByCursor: jest.fn(),
|
||||
};
|
||||
const requestContext = { set: jest.fn() };
|
||||
const metadata = { resolve: jest.fn() };
|
||||
const service = new BrokerageOperationsService(
|
||||
access as never,
|
||||
tbank as never,
|
||||
requestContext as never,
|
||||
metadata as never,
|
||||
);
|
||||
let operationsRequest: unknown;
|
||||
|
||||
@@ -28,6 +30,7 @@ describe('BrokerageOperationsService', () => {
|
||||
jest.clearAllMocks();
|
||||
access.withAccount.mockReset();
|
||||
tbank.getOperationsByCursor.mockReset();
|
||||
metadata.resolve.mockReset();
|
||||
access.withAccount.mockImplementation(
|
||||
(
|
||||
_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 () => {
|
||||
await expect(
|
||||
service.get('user', 'account', { from: '2999-01-01T00:00:00.000Z' }),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mapBrokerageOperation,
|
||||
operationCategory,
|
||||
} from './brokerage-operation.mapper';
|
||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
@@ -27,6 +28,7 @@ export class BrokerageOperationsService {
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly requestContext: RequestContextService,
|
||||
private readonly metadata: InstrumentMetadataService,
|
||||
) {}
|
||||
|
||||
async get(
|
||||
@@ -56,15 +58,25 @@ export class BrokerageOperationsService {
|
||||
limit: query.limit ?? 100,
|
||||
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 = {
|
||||
accountId,
|
||||
items: (response.items ?? [])
|
||||
.filter(
|
||||
(item) =>
|
||||
!query.categories?.length ||
|
||||
query.categories.includes(operationCategory(item.type)),
|
||||
)
|
||||
.map(mapBrokerageOperation),
|
||||
items: await Promise.all(
|
||||
items.map(async (item) => ({
|
||||
...mapBrokerageOperation(item),
|
||||
logoName: await this.logoName(
|
||||
token,
|
||||
item.instrumentUid,
|
||||
item.figi,
|
||||
logoNames,
|
||||
),
|
||||
})),
|
||||
),
|
||||
nextCursor: response.nextCursor || null,
|
||||
hasNext: Boolean(response.hasNext),
|
||||
observedAt: new Date().toISOString(),
|
||||
@@ -83,6 +95,28 @@ export class BrokerageOperationsService {
|
||||
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(
|
||||
|
||||
@@ -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 { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
import type { TbankAccountContext } from './tbank-access.service';
|
||||
|
||||
describe('PortfolioService', () => {
|
||||
@@ -20,11 +21,15 @@ describe('PortfolioService', () => {
|
||||
};
|
||||
const requestContext = { set: jest.fn() };
|
||||
const logger = { event: jest.fn() };
|
||||
const metadata = new InstrumentMetadataService(
|
||||
tbank as never,
|
||||
logger as never,
|
||||
);
|
||||
const service = new PortfolioService(
|
||||
access as never,
|
||||
tbank as never,
|
||||
requestContext as never,
|
||||
logger as never,
|
||||
metadata,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { RequestContextService } from '../common/logging/request-context.service';
|
||||
import { StructuredLogger } from '../common/logging/structured-logger.service';
|
||||
import {
|
||||
add,
|
||||
decimal,
|
||||
@@ -11,11 +10,11 @@ import {
|
||||
} from './decimal';
|
||||
import { MoneyDto, PortfolioDto, PortfolioPositionDto } from './portfolio.dto';
|
||||
import { TbankAccessService } from './tbank-access.service';
|
||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
import { TbankClient, TbankMoney } from './tbank.client';
|
||||
|
||||
const freshForMs = 30_000;
|
||||
const fallbackForMs = 300_000;
|
||||
const metadataForMs = 86_400_000;
|
||||
const types: Record<string, string> = {
|
||||
share: 'STOCK',
|
||||
bond: 'BOND',
|
||||
@@ -28,26 +27,16 @@ const types: Record<string, string> = {
|
||||
};
|
||||
|
||||
type CachedSnapshot = { snapshot: PortfolioDto; storedAt: number };
|
||||
type CachedMetadata = {
|
||||
value: {
|
||||
ticker?: string;
|
||||
name?: string;
|
||||
isin?: string;
|
||||
brand?: { logoName?: string | null };
|
||||
} | null;
|
||||
storedAt: number;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioService {
|
||||
private readonly snapshots = new Map<string, CachedSnapshot>();
|
||||
private readonly metadata = new Map<string, CachedMetadata>();
|
||||
|
||||
constructor(
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly requestContext: RequestContextService,
|
||||
private readonly logger: StructuredLogger,
|
||||
private readonly metadata: InstrumentMetadataService,
|
||||
) {}
|
||||
|
||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
||||
@@ -168,7 +157,7 @@ export class PortfolioService {
|
||||
),
|
||||
}
|
||||
: null;
|
||||
const metadata = await this.instrument(token, instrumentUid, figi);
|
||||
const metadata = await this.metadata.resolve(token, instrumentUid, figi);
|
||||
return {
|
||||
figi,
|
||||
displayName:
|
||||
@@ -202,53 +191,6 @@ export class PortfolioService {
|
||||
? { currency, value: formatDecimal(decimal(value)) }
|
||||
: 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) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
import { BrokerageOperationsController } from './brokerage-operations.controller';
|
||||
import { BrokerageOperationsService } from './brokerage-operations.service';
|
||||
import { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
import { MoexClient } from './moex.client';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
@@ -27,6 +28,7 @@ import { UpcomingEventsService } from './upcoming-events.service';
|
||||
TbankConnectionService,
|
||||
BrokerageAccountsService,
|
||||
BrokerageOperationsService,
|
||||
InstrumentMetadataService,
|
||||
PortfolioService,
|
||||
MoexClient,
|
||||
UpcomingEventsService,
|
||||
|
||||
Reference in New Issue
Block a user