feat: add stock screener
This commit is contained in:
@@ -1,89 +1,274 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { Badge, Group, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ChangeEvent, FC } from "react";
|
||||
|
||||
import {
|
||||
InstrumentSearch,
|
||||
InstrumentSearchCard,
|
||||
} from "@/features/instrument-search";
|
||||
import { formatMoney, formatSnapshotTime } from "@/shared/lib";
|
||||
Alert,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Fieldset,
|
||||
Group,
|
||||
NumberInput,
|
||||
Pagination,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import type { StocksPageVm } from "./StocksPage.vm";
|
||||
import { formatSnapshotTime, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
import type { Criteria, StocksPageVm } from "./StocksPage.vm";
|
||||
|
||||
import { useStocksPagePresentationVm } from "./StocksPagePresentation.vm";
|
||||
|
||||
type CriteriaField = {
|
||||
label: keyof Criteria;
|
||||
options?: readonly string[];
|
||||
type?: "number";
|
||||
};
|
||||
|
||||
type StockCardProps = { item: StocksPageVm["items"][number] };
|
||||
|
||||
type StockCriteriaFieldProps = {
|
||||
criteria: Criteria;
|
||||
field: CriteriaField;
|
||||
onChange: (name: keyof Criteria, value: string) => void;
|
||||
};
|
||||
|
||||
type StocksPageViewProps = { vm: StocksPageVm };
|
||||
|
||||
const criteriaFields: CriteriaField[] = [
|
||||
{ label: "query" },
|
||||
{
|
||||
label: "sector",
|
||||
options: [
|
||||
"consumer",
|
||||
"energy",
|
||||
"financial",
|
||||
"health_care",
|
||||
"industrials",
|
||||
"it",
|
||||
"materials",
|
||||
"real_estate",
|
||||
"telecom",
|
||||
"utilities",
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "shareType",
|
||||
options: [
|
||||
"SHARE_TYPE_ADR",
|
||||
"SHARE_TYPE_COMMON",
|
||||
"SHARE_TYPE_GDR",
|
||||
"SHARE_TYPE_PREFERRED",
|
||||
],
|
||||
},
|
||||
{ label: "currency", options: ["RUB", "USD", "EUR", "CNY", "HKD", "TRY"] },
|
||||
{
|
||||
label: "realExchange",
|
||||
options: [
|
||||
"REAL_EXCHANGE_MOEX",
|
||||
"REAL_EXCHANGE_RTS",
|
||||
"REAL_EXCHANGE_OTC",
|
||||
"REAL_EXCHANGE_DEALER",
|
||||
],
|
||||
},
|
||||
{ label: "countryOfRisk", options: ["RU", "US", "CN", "KZ", "HK", "TR"] },
|
||||
{ label: "maxLot", type: "number" },
|
||||
];
|
||||
|
||||
function numberChange(
|
||||
field: keyof Criteria,
|
||||
onChange: StockCriteriaFieldProps["onChange"],
|
||||
) {
|
||||
return (value: number | string) => onChange(field, String(value));
|
||||
}
|
||||
|
||||
function selectChange(
|
||||
field: keyof Criteria,
|
||||
onChange: StockCriteriaFieldProps["onChange"],
|
||||
) {
|
||||
return (value: null | string) => onChange(field, value ?? "");
|
||||
}
|
||||
|
||||
function selectOptions(field: CriteriaField, translate: ReturnType<typeof useTranslation>["t"]) {
|
||||
return field.options?.map(value => ({
|
||||
label: translate(`stock.screener.options.${field.label}.${value}`, {
|
||||
defaultValue: value,
|
||||
}),
|
||||
value,
|
||||
}));
|
||||
}
|
||||
|
||||
function textChange(
|
||||
field: keyof Criteria,
|
||||
onChange: StockCriteriaFieldProps["onChange"],
|
||||
) {
|
||||
return (event: ChangeEvent<HTMLInputElement>) =>
|
||||
onChange(field, event.currentTarget.value);
|
||||
}
|
||||
|
||||
const StockCriteriaField: FC<StockCriteriaFieldProps> = (props) => {
|
||||
const { criteria, field, onChange } = props;
|
||||
const { t } = useTranslation();
|
||||
const options = selectOptions(field, t);
|
||||
if (field.type === "number") return (
|
||||
<NumberInput
|
||||
label={t(`stock.screener.${field.label}`)}
|
||||
min={1}
|
||||
onChange={numberChange(field.label, onChange)}
|
||||
value={criteria[field.label]}
|
||||
/>
|
||||
);
|
||||
|
||||
if (options) return (
|
||||
<Select
|
||||
clearable
|
||||
data={options}
|
||||
label={t(`stock.screener.${field.label}`)}
|
||||
onChange={selectChange(field.label, onChange)}
|
||||
value={criteria[field.label] || null}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
label={t(`stock.screener.${field.label}`)}
|
||||
onChange={textChange(field.label, onChange)}
|
||||
value={criteria[field.label]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const StockCard: FC<StockCardProps> = (props) => {
|
||||
const { item } = props;
|
||||
const { t } = useTranslation();
|
||||
|
||||
const exchange = item.realExchange
|
||||
? t(`stock.exchanges.${item.realExchange}`, {
|
||||
defaultValue: item.realExchange,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const lot = item.lot === null
|
||||
? undefined
|
||||
: t("stock.lot", { value: item.lot });
|
||||
|
||||
return (
|
||||
<Card p="md" withBorder>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Avatar alt="" radius="md" size={40} src={instrumentLogoUrl(item.logoName)}>
|
||||
{item.name[0]}
|
||||
</Avatar>
|
||||
|
||||
<Stack gap={2} miw={0}>
|
||||
<Anchor
|
||||
c="inherit"
|
||||
component={Link}
|
||||
fw={600}
|
||||
to={`/stocks/${item.instrumentUid}`}
|
||||
>
|
||||
{item.name}
|
||||
</Anchor>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{item.ticker ?? "-"} · {item.shareType ?? "-"}
|
||||
</Text>
|
||||
|
||||
<Group gap="xs">
|
||||
{exchange && <Badge color="gray" variant="light">{exchange}</Badge>}
|
||||
|
||||
{item.currency && (
|
||||
<Badge color="gray" variant="light">{item.currency}</Badge>
|
||||
)}
|
||||
|
||||
{lot && <Text c="dimmed" size="xs">{lot}</Text>}
|
||||
</Group>
|
||||
|
||||
{item.isin && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.isin", { value: item.isin })}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const StocksPageView: FC<StocksPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const { getControlProps, getItemProps } = useStocksPagePresentationVm();
|
||||
|
||||
return (
|
||||
<InstrumentSearch kind="stock" vm={vm}>
|
||||
{vm.items.map((stock) => {
|
||||
const quote = (
|
||||
<>
|
||||
<Text fw={650} size="xl">
|
||||
{stock.price ? formatMoney(stock.price) : "—"}
|
||||
</Text>
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
<Title order={1}>{t("stock.screenerTitle")}</Title>
|
||||
|
||||
<Text c="dimmed" size="xs">
|
||||
{stock.price && stock.priceAt
|
||||
? t("stock.priceAt", {
|
||||
date: formatSnapshotTime(stock.priceAt),
|
||||
})
|
||||
: t("stock.priceUnavailable")}
|
||||
</Text>
|
||||
<Text c="dimmed" mt="xs">{t("stock.screenerSubtitle")}</Text>
|
||||
</div>
|
||||
|
||||
{stock.apiTradeAvailable === true && (
|
||||
<Text c="green.8" mt="sm" size="xs">
|
||||
{t("stock.apiAvailable")}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
<Fieldset disabled={vm.loading} variant="unstyled">
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }}>
|
||||
{criteriaFields.map(field => (
|
||||
<StockCriteriaField
|
||||
criteria={vm.criteria}
|
||||
field={field}
|
||||
key={field.label}
|
||||
onChange={vm.onCriteriaChange}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
return (
|
||||
<InstrumentSearchCard
|
||||
instrument={stock}
|
||||
key={stock.instrumentUid}
|
||||
quote={quote}
|
||||
>
|
||||
<Text c="dimmed" size="sm">
|
||||
{stock.ticker} ·{" "}
|
||||
{t(`stock.shareTypes.${stock.shareType}`, {
|
||||
defaultValue: t("stock.share"),
|
||||
})}
|
||||
</Text>
|
||||
<Group mt="lg">
|
||||
<Button loading={vm.loading} onClick={vm.onSearch}>
|
||||
{t("stock.screener.search")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Fieldset>
|
||||
|
||||
<Group gap="xs" mt="sm">
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`stock.exchanges.${stock.realExchange}`, {
|
||||
defaultValue: t("stock.exchangeUnknown"),
|
||||
})}
|
||||
</Badge>
|
||||
{!!vm.error && (
|
||||
<Alert color="orange" role="status">{t("stock.screener.error")}</Alert>
|
||||
)}
|
||||
|
||||
{!!stock.classCode && (
|
||||
<Text c="dimmed" size="xs">
|
||||
{t("stock.classCode", { code: stock.classCode })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
{vm.resultTime && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{t("stock.screener.asOf", {
|
||||
date: formatSnapshotTime(vm.resultTime),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!!stock.isin && (
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("stock.isin", { value: stock.isin })}
|
||||
</Text>
|
||||
)}
|
||||
{vm.searched && !vm.loading && !vm.error && vm.items.length === 0 && (
|
||||
<Text c="dimmed">{t("stock.screener.empty")}</Text>
|
||||
)}
|
||||
|
||||
{stock.apiTradeAvailable === false && (
|
||||
<Text c="dimmed" mt={4} size="xs">
|
||||
{t("stock.apiUnavailable")}
|
||||
</Text>
|
||||
)}
|
||||
</InstrumentSearchCard>
|
||||
);
|
||||
})}
|
||||
</InstrumentSearch>
|
||||
<Stack gap="sm">
|
||||
{vm.items.map(item => (
|
||||
<StockCard item={item} key={item.instrumentUid} />
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
{vm.totalPages > 1 && (
|
||||
<Group aria-label={t("stock.screener.pagination")} component="nav" justify="center">
|
||||
<Pagination
|
||||
disabled={vm.loading}
|
||||
getControlProps={getControlProps}
|
||||
getItemProps={getItemProps}
|
||||
onChange={vm.onPage}
|
||||
total={vm.totalPages}
|
||||
value={vm.page ?? 1}
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user