feat: add stock screener
Backend verify / commits (pull_request) Successful in 28s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m45s

This commit is contained in:
2026-10-11 11:18:21 +03:00
parent bc631e72de
commit 7d10ef8adf
15 changed files with 901 additions and 1 deletions
@@ -0,0 +1,188 @@
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
import { selectStockScreenerInstruments } from './stock-screener-selection.rules';
const asOf = new Date('2026-10-10T12:00:00.000Z');
function share(overrides: Partial<TbankShare> = {}): TbankShare {
return {
apiTradeAvailableFlag: true,
buyAvailableFlag: true,
currency: 'RUB',
lot: 10,
name: 'North Star',
ticker: 'NSTR',
uid: 'uid-1',
...overrides,
};
}
describe('selectStockScreenerInstruments', () => {
it('keeps only instruments matching every selected condition', () => {
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [
share({ isin: 'A', sector: 'energy' }),
share({ currency: 'USD', isin: 'B', sector: 'energy', uid: 'uid-2' }),
share({ isin: 'C', name: 'Other', sector: 'energy', uid: 'uid-3' }),
share({ isin: 'D', sector: 'finance', uid: 'uid-4' }),
],
},
{ currency: 'RUB', query: 'star', sector: 'energy' },
);
expect(result).toEqual({ asOf, instruments: [share({ isin: 'A', sector: 'energy' })] });
});
it.each([
['sector', { sector: 'energy' }, share({ sector: undefined })],
['share type', { shareType: 'common' }, share({ shareType: 'preferred' })],
['currency', { currency: 'USD' }, share({ currency: undefined })],
['exchange', { realExchange: 'MOEX' }, share({ realExchange: undefined })],
['country of risk', { countryOfRisk: 'RU' }, share({ countryOfRisk: 'US' })],
['query', { query: 'star' }, share({ name: undefined, ticker: undefined })],
] as const)('excludes an unknown or unequal selected %s', (_condition, conditions, instrument) => {
const result = selectStockScreenerInstruments(
{ asOf, instruments: [instrument] },
conditions,
);
expect(result.instruments).toEqual([]);
});
it('uses an inclusive maximum lot and ignores lots when it is not selected', () => {
const instruments = [
share({ isin: 'equal', lot: 10, uid: 'equal' }),
share({ isin: 'large', lot: 11, uid: 'large' }),
share({ isin: 'unknown', lot: undefined, uid: 'unknown' }),
share({ isin: 'zero', lot: 0, uid: 'zero' }),
];
expect(
selectStockScreenerInstruments({ asOf, instruments }, { maxLot: 10 }).instruments.map(
({ uid }) => uid,
),
).toEqual(['equal']);
expect(
selectStockScreenerInstruments({ asOf, instruments }, {}).instruments.map(({ uid }) => uid),
).toEqual(['equal', 'large', 'unknown', 'zero']);
});
it('chooses the smallest matching uid as the complete representative of an ISIN', () => {
const chosen = share({ isin: ' same ', lot: 1, name: 'Chosen', uid: 'a' });
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [share({ isin: 'SAME', lot: 99, name: 'Sibling', uid: 'z' }), chosen],
},
{},
);
expect(result.instruments).toEqual([chosen]);
expect(result.instruments[0]).toMatchObject({ lot: 1, name: 'Chosen', uid: 'a' });
});
it('chooses the sole matching mode of an issue', () => {
const matching = share({ isin: 'same', sector: 'energy', uid: 'matching' });
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [share({ isin: 'same', sector: 'finance', uid: 'smaller-uid' }), matching],
},
{ sector: 'energy' },
);
expect(result.instruments).toEqual([matching]);
});
it('keeps different ISIN issues from one issuer separate', () => {
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [
share({ isin: 'common', shareType: 'common', uid: 'common' }),
share({ isin: 'preferred', shareType: 'preferred', uid: 'preferred' }),
],
},
{},
);
expect(result.instruments.map(({ uid }) => uid)).toEqual(['common', 'preferred']);
});
it('treats instruments without an ISIN as separate issues', () => {
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [share({ isin: ' ', uid: 'first' }), share({ isin: undefined, uid: 'second' })],
},
{},
);
expect(result.instruments.map(({ uid }) => uid)).toEqual(['first', 'second']);
});
it('preserves exact whitespace-differing UIDs as separate blank-ISIN issues', () => {
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [
share({ isin: ' ', uid: 'uid' }),
share({ isin: undefined, uid: ' uid ' }),
],
},
{},
);
expect(result.instruments.map(({ uid }) => uid)).toEqual([' uid ', 'uid']);
});
it('returns the exact chosen uid for the later card link', () => {
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [share({ isin: 'same', uid: 'sibling' }), share({ isin: 'SAME', uid: 'chosen' })],
},
{},
);
expect(result.instruments[0]?.uid).toBe('chosen');
});
it.each([
[true, true, 1],
[true, false, 0],
[false, true, 0],
[false, false, 0],
[true, undefined, 0],
[undefined, true, 0],
] as const)(
'includes only confirmed buy and API availability: buy=%s api=%s',
(buyAvailableFlag, apiTradeAvailableFlag, expectedCount) => {
const result = selectStockScreenerInstruments(
{ asOf, instruments: [share({ apiTradeAvailableFlag, buyAvailableFlag })] },
{},
);
expect(result.instruments).toHaveLength(expectedCount);
},
);
it('orders representatives by ticker and then uid before paging', () => {
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [
share({ isin: 'c', ticker: 'B', uid: 'z' }),
share({ isin: 'a', ticker: 'A', uid: 'z' }),
share({ isin: 'b', ticker: 'A', uid: 'a' }),
share({ isin: 'empty', ticker: ' ', uid: 'empty' }),
],
},
{},
);
expect(result.instruments.map(({ uid }) => uid)).toEqual(['empty', 'a', 'z', 'z']);
});
});
@@ -0,0 +1,106 @@
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
export type StockScreenerCatalog = {
asOf: Date;
instruments: TbankShare[];
};
export type StockScreenerConditions = {
countryOfRisk?: string;
currency?: string;
maxLot?: number;
query?: string;
realExchange?: string;
sector?: string;
shareType?: string;
};
type IdentifiedShare = TbankShare & { uid: string };
const sortBefore = 0;
export function selectStockScreenerInstruments(
catalog: StockScreenerCatalog,
conditions: StockScreenerConditions,
): StockScreenerCatalog {
const representatives = new Map<string, IdentifiedShare>();
for (const instrument of catalog.instruments) {
if (!matches(instrument, conditions)) continue;
const { uid } = instrument;
const isin = instrument.isin?.trim().toUpperCase();
const issueKey = isin ? `isin:${isin}` : `uid:${uid}`;
const existing = representatives.get(issueKey);
if (!existing || uid.localeCompare(existing.uid) < sortBefore) {
representatives.set(issueKey, instrument);
}
}
return {
asOf: catalog.asOf,
instruments: [...representatives.values()].sort(
(left, right) =>
(left.ticker?.trim() ?? '').localeCompare(right.ticker?.trim() ?? '') ||
left.uid.localeCompare(right.uid),
),
};
}
function matches(
instrument: TbankShare,
conditions: StockScreenerConditions,
): instrument is IdentifiedShare {
if (
!instrument.uid?.trim() ||
instrument.buyAvailableFlag !== true ||
instrument.apiTradeAvailableFlag !== true
) {
return false;
}
if (
conditions.query !== undefined &&
!`${instrument.name ?? ''}\n${instrument.ticker ?? ''}`
.toLowerCase()
.includes(conditions.query.toLowerCase())
) {
return false;
}
if (conditions.sector !== undefined && instrument.sector !== conditions.sector) {
return false;
}
if (
conditions.shareType !== undefined &&
instrument.shareType !== conditions.shareType
) {
return false;
}
if (
conditions.currency !== undefined &&
instrument.currency !== conditions.currency
) {
return false;
}
if (
conditions.realExchange !== undefined &&
instrument.realExchange !== conditions.realExchange
) {
return false;
}
if (
conditions.countryOfRisk !== undefined &&
instrument.countryOfRisk !== conditions.countryOfRisk
) {
return false;
}
const minimumPositiveLot = 0;
return (
conditions.maxLot === undefined ||
(Number.isInteger(instrument.lot) &&
instrument.lot !== undefined &&
instrument.lot > minimumPositiveLot &&
instrument.lot <= conditions.maxLot)
);
}