feat: add stock screener

This commit is contained in:
2026-10-11 10:44:30 +03:00
parent 6b1b26aa34
commit ba0a5d46cf
9 changed files with 1102 additions and 157 deletions
+107 -34
View File
@@ -1,43 +1,116 @@
import { MIN_STOCK_SEARCH_LENGTH, useStockSearch } from "@/entities/stock";
import { useInstrumentSearch } from "@/features/instrument-search";
import { useScreenSync } from "@/features/tbank-connection";
import { apiErrorCode } from "@/shared/api";
import { instrumentLogoUrl } from "@/shared/lib";
import { useRef, useState } from "react";
import { apiClient } from "@/shared/api";
type Criteria = {
countryOfRisk: string;
currency: string;
maxLot: string;
query: string;
realExchange: string;
sector: string;
shareType: string;
};
type ScreenerPage = Awaited<ReturnType<typeof readPage>>;
type StocksPageVm = ReturnType<typeof useStocksPageVm>;
const emptyCriteria: Criteria = {
countryOfRisk: "",
currency: "",
maxLot: "",
query: "",
realExchange: "",
sector: "",
shareType: "",
};
async function createSearch(criteria: Criteria) {
const { data } = await apiClient.POST("/api/v1/stocks/screener/search", {
body: {
...(criteria.countryOfRisk && { countryOfRisk: criteria.countryOfRisk }),
...(criteria.currency && { currency: criteria.currency }),
...(criteria.maxLot && { maxLot: Number(criteria.maxLot) }),
...(criteria.query && { query: criteria.query }),
...(criteria.realExchange && { realExchange: criteria.realExchange }),
...(criteria.sector && { sector: criteria.sector }),
...(criteria.shareType && { shareType: criteria.shareType }),
},
});
if (!data) throw new Error("Stock screener search failed");
return data;
}
async function readPage(searchId: string, page: number) {
const { data } = await apiClient.GET(
"/api/v1/stocks/screener/search/{searchId}",
{ params: { path: { searchId }, query: { page } } },
);
if (!data) throw new Error("Stock screener page failed");
return data;
}
function useStocksPageVm() {
const search = useInstrumentSearch(MIN_STOCK_SEARCH_LENGTH);
const { canSearch, debounced, isWaiting, query } = search;
const result = useStockSearch(debounced);
useScreenSync({ query: debounced, screen: "stocks" }, [result]);
const [criteria, setCriteria] = useState(emptyCriteria);
const [page, setPage] = useState<null | ScreenerPage>(null);
const [pending, setPending] = useState(false);
const [error, setError] = useState(false);
const requestIdRef = useRef(0);
const search = async () => {
const currentRequestId = requestIdRef.current + 1;
requestIdRef.current = currentRequestId;
setPending(true);
setError(false);
try {
const result = await createSearch(criteria);
if (requestIdRef.current === currentRequestId) setPage(result);
}
catch {
if (requestIdRef.current === currentRequestId) setError(true);
}
finally {
if (requestIdRef.current === currentRequestId) setPending(false);
}
};
const changePage = async (nextPage: number) => {
if (!page) return;
setPending(true);
setError(false);
try {
setPage(await readPage(page.searchId, nextPage));
}
catch {
setError(true);
}
finally {
setPending(false);
}
};
return {
canSearch,
code: apiErrorCode(result.error),
error:
!result.data
&& (result.isError || result.meta?.lastAttempt.state === "failed"),
items:
result.data?.map(stock => ({
...stock,
logoSrc: instrumentLogoUrl(stock.logoName),
to: `/stocks/${encodeURIComponent(stock.slug)}`,
})) ?? [],
loading:
canSearch
&& (isWaiting
|| (!result.data
&& !result.isError
&& result.meta?.lastAttempt.state !== "failed")),
onQueryChange: search.onQueryChange,
partial: result.meta?.completeness === "incomplete",
query,
ready:
debounced.length >= MIN_STOCK_SEARCH_LENGTH
&& !isWaiting
&& Boolean(result.data),
criteria,
error,
items: page?.items ?? [],
loading: pending,
onCriteriaChange: (name: keyof Criteria, value: string) =>
setCriteria(current => ({ ...current, [name]: value })),
onPage: (nextPage: number) => void changePage(nextPage),
onSearch: () => void search(),
page: page?.page,
resultTime: page?.asOf,
searched: page !== null,
totalPages: page ? page.page + Number(page.hasNext) : 0,
};
}
export { type StocksPageVm, useStocksPageVm };
export { type Criteria, type StocksPageVm, useStocksPageVm };