feat: add stock screener
This commit is contained in:
@@ -133,6 +133,18 @@ export class TbankAccessService {
|
||||
return this.tokens.decrypt(connection.encryptedToken);
|
||||
}
|
||||
|
||||
async current(userId: string): Promise<{ revision: string; token: string }> {
|
||||
const connection = await this.prisma.tbankConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { encryptedToken: true, invalidToken: true, revision: true },
|
||||
});
|
||||
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
|
||||
if (connection.invalidToken) {
|
||||
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||
}
|
||||
return { revision: connection.revision, token: this.tokens.decrypt(connection.encryptedToken) };
|
||||
}
|
||||
|
||||
async publishMembership(
|
||||
attempt: TbankSyncAttempt,
|
||||
accounts: TbankAccount[],
|
||||
|
||||
@@ -8,7 +8,9 @@ import type {
|
||||
export type TbankShare = TbankInstrument & {
|
||||
apiTradeAvailableFlag?: boolean;
|
||||
assetUid?: string;
|
||||
buyAvailableFlag?: boolean;
|
||||
classCode?: string;
|
||||
countryOfRisk?: string;
|
||||
currency?: string;
|
||||
figi?: string;
|
||||
first_1dayCandleDate?: TbankTimestamp;
|
||||
|
||||
@@ -5,6 +5,10 @@ export enum InstrumentIdType {
|
||||
Uid = 'INSTRUMENT_ID_TYPE_UID',
|
||||
}
|
||||
|
||||
export enum TbankInstrumentStatus {
|
||||
All = 'INSTRUMENT_STATUS_ALL',
|
||||
}
|
||||
|
||||
export enum TbankInstrumentType {
|
||||
Bond = 'INSTRUMENT_TYPE_BOND',
|
||||
Share = 'INSTRUMENT_TYPE_SHARE',
|
||||
|
||||
@@ -282,3 +282,141 @@ describe('TbankClient metadata retries', () => {
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TbankClient share catalog', () => {
|
||||
const token = 'token';
|
||||
const catalogSize = 40;
|
||||
let client: TbankClient;
|
||||
let call: jest.Mock;
|
||||
let logger: {
|
||||
event: jest.Mock<void, [string, string, Record<string, number | string>]>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
call = jest.fn();
|
||||
logger = {
|
||||
event: jest.fn<void, [string, string, Record<string, number | string>]>(),
|
||||
};
|
||||
client = Object.assign(
|
||||
Object.create(TbankClient.prototype) as TbankClient,
|
||||
{ call, logger },
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches the full share catalog through Shares with INSTRUMENT_STATUS_ALL', async () => {
|
||||
const instruments = [{ ticker: 'AFLT', uid: 'uid-aflt' }];
|
||||
call.mockResolvedValue({ instruments });
|
||||
|
||||
await expect(client.getAllShares(token)).resolves.toEqual(instruments);
|
||||
expect(call).toHaveBeenCalledWith('token', undefined, 'shares', {
|
||||
instrumentStatus: 'INSTRUMENT_STATUS_ALL',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the whole catalog including a fitting instrument beyond the first 30 text matches', async () => {
|
||||
const catalog = Array.from({ length: catalogSize }, (_, index) => {
|
||||
const fitting = index === catalogSize - 1;
|
||||
return {
|
||||
apiTradeAvailableFlag: fitting,
|
||||
buyAvailableFlag: fitting,
|
||||
ticker: fitting ? 'FIT' : `T${index}`,
|
||||
uid: fitting ? 'uid-fit' : `uid-${index}`,
|
||||
};
|
||||
});
|
||||
call.mockResolvedValue({ instruments: catalog });
|
||||
|
||||
const shares = await client.getAllShares(token);
|
||||
|
||||
expect(shares).toEqual(catalog);
|
||||
expect(shares).toHaveLength(catalogSize);
|
||||
expect(shares).toContainEqual({
|
||||
apiTradeAvailableFlag: true,
|
||||
buyAvailableFlag: true,
|
||||
ticker: 'FIT',
|
||||
uid: 'uid-fit',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when the source refuses instead of returning an empty success', async () => {
|
||||
const error = new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
call.mockRejectedValue(error);
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('rejects when the response carries no instrument list', async () => {
|
||||
call.mockResolvedValue({});
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an empty instrument list with the public unavailable code', async () => {
|
||||
call.mockResolvedValue({ instruments: [] });
|
||||
|
||||
const failure = await client
|
||||
.getAllShares(token)
|
||||
.catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(ServiceUnavailableException);
|
||||
expect((failure as ServiceUnavailableException).getResponse()).toEqual({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a non-list instrument payload as uninterpretable', async () => {
|
||||
call.mockResolvedValue({ instruments: 'corrupted' });
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an uninterpretable entry without instrument identity', async () => {
|
||||
call.mockResolvedValue({
|
||||
instruments: [
|
||||
{ ticker: 'FIT', uid: 'uid-fit' },
|
||||
{ ticker: 'BROKEN' },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('registers asOf and source instrument count on a successful catalog fetch', async () => {
|
||||
call.mockResolvedValue({
|
||||
instruments: [{ uid: 'uid-a' }, { uid: 'uid-b' }],
|
||||
});
|
||||
|
||||
await client.getAllShares(token);
|
||||
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'tbank.shares.catalog.fetched',
|
||||
expect.objectContaining({ count: 2, operation: 'shares' }),
|
||||
);
|
||||
expect(logger.event.mock.calls[0][2].asOf).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('logs a distinguishable failure without secrets when the catalog is unusable', async () => {
|
||||
call.mockResolvedValue({ instruments: [] });
|
||||
|
||||
await expect(client.getAllShares('secret-token')).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'tbank.shares.catalog.unusable',
|
||||
expect.objectContaining({ operation: 'shares' }),
|
||||
);
|
||||
expect(JSON.stringify(logger.event.mock.calls)).not.toContain(
|
||||
'secret-token',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
InstrumentIdType,
|
||||
TbankAccountStatus,
|
||||
TbankCandleInterval,
|
||||
TbankInstrumentStatus,
|
||||
TbankInstrumentType,
|
||||
TbankOperationState,
|
||||
} from './tbank-protocol';
|
||||
@@ -325,6 +326,35 @@ export class TbankClient {
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
|
||||
async getAllShares(token: string): Promise<TbankShare[]> {
|
||||
const response = await this.call<{ instruments?: TbankShare[] }>(
|
||||
token,
|
||||
this.instruments,
|
||||
'shares',
|
||||
{ instrumentStatus: TbankInstrumentStatus.All },
|
||||
);
|
||||
const { instruments } = response;
|
||||
if (
|
||||
!Array.isArray(instruments) ||
|
||||
!instruments.length ||
|
||||
instruments.some((item) => !item?.uid)
|
||||
) {
|
||||
this.logger.event('error', 'tbank.shares.catalog.unusable', {
|
||||
code: TbankErrorCode.Unavailable,
|
||||
operation: 'shares',
|
||||
});
|
||||
throw new ServiceUnavailableException({
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
}
|
||||
this.logger.event('info', 'tbank.shares.catalog.fetched', {
|
||||
asOf: new Date().toISOString(),
|
||||
count: instruments.length,
|
||||
operation: 'shares',
|
||||
});
|
||||
return instruments;
|
||||
}
|
||||
|
||||
findBonds(token: string, query: string): Promise<TbankBond[]> {
|
||||
return this.call<{ instruments?: TbankBond[] }>(
|
||||
token,
|
||||
|
||||
Reference in New Issue
Block a user