feat: add bond pages and portfolio navigation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export { BondPage } from "./ui/BondPage";
|
||||
@@ -0,0 +1,47 @@
|
||||
.page {
|
||||
max-width: 1120px;
|
||||
margin: 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.section {
|
||||
padding: var(--mantine-spacing-lg);
|
||||
border-radius: var(--mantine-radius-lg);
|
||||
min-width: 0;
|
||||
}
|
||||
.identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--mantine-spacing-md);
|
||||
min-width: 0;
|
||||
}
|
||||
.price {
|
||||
font-size: clamp(2rem, 4vw, 3.2rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.04em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.companyMetrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(135px, 1fr));
|
||||
gap: var(--mantine-spacing-lg);
|
||||
}
|
||||
.embeddedSection {
|
||||
margin-top: var(--mantine-spacing-lg);
|
||||
padding-top: var(--mantine-spacing-lg);
|
||||
border-top: 1px solid var(--wl-color-border);
|
||||
}
|
||||
.companyMetrics > div {
|
||||
padding: var(--mantine-spacing-sm);
|
||||
background: var(--wl-color-subtle);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
}
|
||||
@media (max-width: 48em) {
|
||||
.section {
|
||||
padding: var(--mantine-spacing-md);
|
||||
}
|
||||
.companyMetrics {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { HttpResponse, http } from "msw";
|
||||
import { MemoryRouter, Route, Routes, useLocation } from "react-router";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { configureAuthClient } from "@/shared/api";
|
||||
import { createTestQueryClient } from "@/test/query";
|
||||
import { server } from "@/test/server";
|
||||
import { BondPage } from "./BondPage";
|
||||
|
||||
const LocationProbe = () => {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<output data-testid="location">
|
||||
{location.pathname}
|
||||
{location.search}
|
||||
</output>
|
||||
);
|
||||
};
|
||||
|
||||
const uid = "e6123145-9665-43e0-8413-cd61b8aa9b13";
|
||||
const money = (value: string) => ({ value, currency: "RUB" });
|
||||
const bond = {
|
||||
instrumentUid: uid,
|
||||
name: "Тестовая облигация",
|
||||
ticker: "TEST",
|
||||
nominal: money("500"),
|
||||
initialNominal: money("1000"),
|
||||
aci: money("12"),
|
||||
maturityDate: "2030-05-15",
|
||||
callDate: null,
|
||||
couponQuantityPerYear: 4,
|
||||
floatingCoupon: true,
|
||||
amortization: true,
|
||||
perpetual: false,
|
||||
subordinated: false,
|
||||
isin: "TESTISIN",
|
||||
currency: "RUB",
|
||||
sector: "financial",
|
||||
lot: 10,
|
||||
logoName: null,
|
||||
historyFrom: null,
|
||||
};
|
||||
const position = {
|
||||
quantity: "10",
|
||||
currentValue: money("3000"),
|
||||
averagePurchasePrice: money("200"),
|
||||
absoluteReturn: money("1000"),
|
||||
returnPercent: "50",
|
||||
accounts: [
|
||||
{
|
||||
instrumentUid: uid,
|
||||
quantity: "10",
|
||||
currentValue: money("3000"),
|
||||
averagePurchasePrice: money("200"),
|
||||
absoluteReturn: money("1000"),
|
||||
returnPercent: "50",
|
||||
account: { id: "main", name: "Основной" },
|
||||
},
|
||||
],
|
||||
isStale: false,
|
||||
observedAt: "2026-09-10T10:00:00Z",
|
||||
};
|
||||
const quote = {
|
||||
pricePercent: "98.125",
|
||||
cleanPrice: money("490.625"),
|
||||
aci: money("12"),
|
||||
dirtyPrice: money("502.625"),
|
||||
priceAt: "2026-09-10T10:00:00Z",
|
||||
tradingStatus: null,
|
||||
changePercentagePoints: "-0.875",
|
||||
previousCloseDate: "2026-09-09",
|
||||
unavailable: [],
|
||||
observedAt: "2026-09-10T10:00:00Z",
|
||||
};
|
||||
function setup(
|
||||
options: {
|
||||
noPosition?: boolean;
|
||||
failCoupons?: boolean;
|
||||
ratingState?: "active" | "withdrawn" | "missing" | "unavailable";
|
||||
failBond?: boolean;
|
||||
path?: string;
|
||||
slug?: string;
|
||||
ambiguousTicker?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const requests: URL[] = [];
|
||||
const resolved = { ...bond, slug: options.slug ?? uid };
|
||||
let failQuote = false;
|
||||
server.use(
|
||||
http.get("*/api/v1/bonds/:uid", () =>
|
||||
options.failBond
|
||||
? HttpResponse.json({ code: "NOT_FOUND" }, { status: 404 })
|
||||
: HttpResponse.json(resolved),
|
||||
),
|
||||
http.get("*/api/v1/bonds/by-ticker/:ticker", ({ request }) => {
|
||||
requests.push(new URL(request.url));
|
||||
return options.ambiguousTicker
|
||||
? HttpResponse.json({ code: "BOND_TICKER_AMBIGUOUS" }, { status: 409 })
|
||||
: HttpResponse.json(resolved);
|
||||
}),
|
||||
http.get("*/api/v1/bonds/:uid/:section", ({ params, request }) => {
|
||||
requests.push(new URL(request.url));
|
||||
if (params.section === "metrics")
|
||||
return HttpResponse.json({
|
||||
creditRating:
|
||||
options.ratingState === "missing" ||
|
||||
options.ratingState === "unavailable"
|
||||
? null
|
||||
: {
|
||||
agency: "NCR",
|
||||
value: options.ratingState === "withdrawn" ? null : "AAA.ru",
|
||||
status:
|
||||
options.ratingState === "withdrawn"
|
||||
? "withdrawn"
|
||||
: "active",
|
||||
date: "2026-04-17",
|
||||
sourceUrl: "https://ratings.ru/ratings/issues/123/",
|
||||
observedAt: quote.observedAt,
|
||||
},
|
||||
coupon: money("32.88"),
|
||||
couponDate: "2026-10-27",
|
||||
couponType: "COUPON_TYPE_FLOATING",
|
||||
couponPeriodDays: 90,
|
||||
couponYieldPercent: "27.18",
|
||||
couponYieldPriceAt: quote.priceAt,
|
||||
effectiveYield: {
|
||||
value: "15.6262",
|
||||
pricePercent: "53.384",
|
||||
date: "2030-05-15",
|
||||
dateType: "MATDATE",
|
||||
tradeAt: quote.priceAt,
|
||||
},
|
||||
unavailable: options.ratingState === "unavailable" ? ["rating"] : [],
|
||||
observedAt: quote.observedAt,
|
||||
});
|
||||
if (params.section === "quote")
|
||||
return failQuote
|
||||
? HttpResponse.json({ code: "TBANK_UNAVAILABLE" }, { status: 503 })
|
||||
: HttpResponse.json(quote);
|
||||
if (params.section === "position")
|
||||
return HttpResponse.json(
|
||||
options.noPosition
|
||||
? { ...position, quantity: "0", accounts: [] }
|
||||
: position,
|
||||
);
|
||||
if (params.section === "candles")
|
||||
return HttpResponse.json({
|
||||
candles: [
|
||||
{ time: "2026-09-09T00:00:00Z", close: "99.25", isComplete: true },
|
||||
],
|
||||
currency: null,
|
||||
historyFrom: null,
|
||||
observedAt: quote.observedAt,
|
||||
});
|
||||
if (options.failCoupons)
|
||||
return HttpResponse.json(
|
||||
{ code: "TBANK_UNAVAILABLE" },
|
||||
{ status: 503 },
|
||||
);
|
||||
return HttpResponse.json({
|
||||
coupons: [
|
||||
{
|
||||
perBond: money("32.88"),
|
||||
recordDate: "2026-10-13",
|
||||
number: "1",
|
||||
couponType: "COUPON_TYPE_FLOATING",
|
||||
periodDays: 90,
|
||||
paymentDate: "2026-10-27",
|
||||
timing: "upcoming",
|
||||
estimatedAmount: options.noPosition ? null : money("328.8"),
|
||||
},
|
||||
{
|
||||
perBond: money("20"),
|
||||
recordDate: "2025-10-13",
|
||||
paymentDate: null,
|
||||
number: "2",
|
||||
couponType: null,
|
||||
periodDays: null,
|
||||
timing: "past",
|
||||
estimatedAmount: null,
|
||||
},
|
||||
],
|
||||
from: "2021-09-10",
|
||||
to: "2027-09-10",
|
||||
observedAt: quote.observedAt,
|
||||
positionAt: null,
|
||||
estimateUnavailable: false,
|
||||
positionIsStale: false,
|
||||
});
|
||||
}),
|
||||
http.get("*/api/v1/brokerage-accounts/all/operations", ({ request }) => {
|
||||
const url = new URL(request.url);
|
||||
requests.push(url);
|
||||
const next = url.searchParams.has("cursor");
|
||||
return HttpResponse.json({
|
||||
accountId: "all",
|
||||
observedAt: quote.observedAt,
|
||||
hasNext: !next,
|
||||
nextCursor: next ? null : "next",
|
||||
items: [
|
||||
{
|
||||
id: next ? "sold" : "dividend",
|
||||
occurredAt: next ? "2022-01-02T10:00:00Z" : "2025-01-02T10:00:00Z",
|
||||
type: next ? "OPERATION_TYPE_SELL" : "OPERATION_TYPE_COUPON",
|
||||
category: next ? "trade" : "income",
|
||||
description: "",
|
||||
account: { id: "main", name: "Основной" },
|
||||
payment: money("200"),
|
||||
quantity: next ? "2" : null,
|
||||
price: next ? money("100") : null,
|
||||
},
|
||||
],
|
||||
});
|
||||
}),
|
||||
);
|
||||
configureAuthClient({
|
||||
accessToken: () => "synthetic",
|
||||
refresh: async () => false,
|
||||
clear: vi.fn(),
|
||||
});
|
||||
const client = createTestQueryClient();
|
||||
render(
|
||||
<QueryClientProvider client={client}>
|
||||
<MantineProvider>
|
||||
<MemoryRouter initialEntries={[options.path ?? `/bonds/${uid}`]}>
|
||||
<LocationProbe />
|
||||
<Routes>
|
||||
<Route path="/bonds/:bondId" element={<BondPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return {
|
||||
requests,
|
||||
failQuote: () => {
|
||||
failQuote = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
describe("bond page", () => {
|
||||
it("opens a direct ticker, keeps percentages distinct from money, and filters personal history by UID", async () => {
|
||||
const { requests } = setup({ path: "/bonds/TEST", slug: "TEST" });
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "Тестовая облигация" }),
|
||||
).toBeVisible();
|
||||
expect(await screen.findByText("98,125 %")).toBeVisible();
|
||||
expect(screen.getByText("490,625 ₽")).toBeVisible();
|
||||
expect(screen.getByText("502,625 ₽")).toBeVisible();
|
||||
expect(screen.getByText("Плавающий купон")).toBeVisible();
|
||||
expect(screen.getByText("15.05.2030")).toBeVisible();
|
||||
expect(await screen.findByText("15,6262 %")).toBeVisible();
|
||||
expect(screen.getByText("AAA.ru")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /НКР · рейтинг выпуска/ }),
|
||||
).toHaveAttribute("href", "https://ratings.ru/ratings/issues/123/");
|
||||
expect(screen.getByText("27,18 %")).toBeVisible();
|
||||
expect(screen.getByText(/MOEX · к погашению/)).toBeVisible();
|
||||
expect(await screen.findByText("328,8 ₽")).toBeVisible();
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
requests.some((url) => url.searchParams.get("instrumentUid") === uid),
|
||||
).toBe(true),
|
||||
);
|
||||
expect(
|
||||
requests.some((url) => url.pathname === `/api/v1/bonds/${uid}/quote`),
|
||||
).toBe(true);
|
||||
fireEvent.click(screen.getByRole("button", { name: /Цены в таблице/ }));
|
||||
await waitFor(() => expect(screen.getByText("99,25 %")).toBeVisible());
|
||||
expect(screen.queryByText("99,25 ₽")).not.toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Прошедшие и недатированные купоны" }),
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("20 ₽")).toBeVisible());
|
||||
});
|
||||
it.each([
|
||||
["withdrawn", "Отозван"],
|
||||
["missing", "В реестре НКР не найден"],
|
||||
["unavailable", "Источник недоступен"],
|
||||
] as const)(
|
||||
"distinguishes %s credit rating while keeping yield visible",
|
||||
async (ratingState, expected) => {
|
||||
setup({ ratingState });
|
||||
expect(await screen.findByText(expected)).toBeVisible();
|
||||
expect(screen.queryByText("AAA.ru")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("15,6262 %")).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
it("canonicalizes a UID link while preserving period and event filters", async () => {
|
||||
setup({ path: `/bonds/${uid}?period=year&events=trades`, slug: "TEST" });
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("location")).toHaveTextContent(
|
||||
"/bonds/TEST?period=year&events=trades",
|
||||
),
|
||||
);
|
||||
});
|
||||
it("keeps market content and sold-position history when coupons fail", async () => {
|
||||
setup({ noPosition: true, failCoupons: true });
|
||||
await screen.findByText("98,125 %");
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"Не удалось загрузить этот раздел",
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "Моя позиция" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Моя история событий" }),
|
||||
).toBeVisible();
|
||||
});
|
||||
it("preserves cached quote on refresh failure", async () => {
|
||||
const controls = setup();
|
||||
await screen.findByText("98,125 %");
|
||||
controls.failQuote();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Обновить цену" }));
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent(
|
||||
"последние полученные",
|
||||
);
|
||||
expect(screen.getByText("98,125 %")).toBeVisible();
|
||||
});
|
||||
it("offers search for an ambiguous or missing instrument without loading personal data", async () => {
|
||||
const { requests } = setup({ path: "/bonds/SAME", ambiguousTicker: true });
|
||||
expect(
|
||||
await screen.findByText(/Тикер соответствует нескольким выпускам/),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "К поиску облигаций" }),
|
||||
).toHaveAttribute("href", "/bonds");
|
||||
expect(requests.some((url) => url.pathname.endsWith("/position"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it("resets continuation when selecting coupons and supports changing the chart period", async () => {
|
||||
const { requests } = setup();
|
||||
await screen.findByText("98,125 %");
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Далее" }));
|
||||
expect(await screen.findByText("Продажа")).toBeVisible();
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Купоны" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
requests.some(
|
||||
(url) =>
|
||||
url.searchParams.get("categories") === "income" &&
|
||||
!url.searchParams.has("cursor"),
|
||||
),
|
||||
).toBe(true),
|
||||
);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Год" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
requests.some((url) => url.searchParams.get("period") === "year"),
|
||||
).toBe(true),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Anchor, Button, Skeleton, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link, Navigate } from "react-router";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
import { BondPageView } from "./BondPage.view";
|
||||
import { useBondPageVm } from "./BondPage.vm";
|
||||
|
||||
const BondPage: FC = () => {
|
||||
const vm = useBondPageVm();
|
||||
const { t } = useTranslation();
|
||||
if (vm.canonicalTo) return <Navigate to={vm.canonicalTo} replace />;
|
||||
if (vm.bondState.loading)
|
||||
return (
|
||||
<LoadingSkeleton label={t("bond.loading")}>
|
||||
<Stack>
|
||||
<Skeleton h={110} />
|
||||
<Skeleton h={350} />
|
||||
<Skeleton h={170} />
|
||||
</Stack>
|
||||
</LoadingSkeleton>
|
||||
);
|
||||
if (!vm.bond)
|
||||
return (
|
||||
<Stack role="alert">
|
||||
<Title order={1}>{t("bond.unavailable")}</Title>
|
||||
<Text>
|
||||
{t(
|
||||
vm.code === "TBANK_NOT_CONNECTED" ||
|
||||
vm.code === "INVALID_TBANK_TOKEN"
|
||||
? "bond.connect"
|
||||
: vm.code === "BOND_TICKER_AMBIGUOUS"
|
||||
? "bond.ambiguousTicker"
|
||||
: "bond.unavailableText",
|
||||
)}
|
||||
</Text>
|
||||
<Anchor component={Link} to="/bonds">
|
||||
{t("bond.backToSearch")}
|
||||
</Anchor>
|
||||
<Anchor component={Link} to="/settings">
|
||||
{t("accounts.openSettings")}
|
||||
</Anchor>
|
||||
<Button variant="default" onClick={vm.bondState.retry}>
|
||||
{t("accounts.retry")}
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
return <BondPageView vm={vm} />;
|
||||
};
|
||||
|
||||
export { BondPage };
|
||||
@@ -0,0 +1,446 @@
|
||||
import {
|
||||
Accordion,
|
||||
Alert,
|
||||
Anchor,
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import type { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
InstrumentChart,
|
||||
InstrumentEvents,
|
||||
InstrumentPosition,
|
||||
InstrumentSection,
|
||||
} from "@/features/instrument-details";
|
||||
import {
|
||||
formatDate,
|
||||
formatDecimal,
|
||||
formatMoney,
|
||||
formatSnapshotTime,
|
||||
} from "@/shared/lib";
|
||||
import { LoadingSkeleton } from "@/shared/ui";
|
||||
import classes from "./BondPage.module.css";
|
||||
import type { BondPageVm } from "./BondPage.vm";
|
||||
|
||||
type BondPageViewProps = { vm: BondPageVm };
|
||||
const BondPageView: FC<BondPageViewProps> = (props) => {
|
||||
const { vm } = props;
|
||||
const { t } = useTranslation();
|
||||
const bond = vm.bond;
|
||||
if (!bond) return null;
|
||||
return (
|
||||
<Stack className={classes.page} gap="lg">
|
||||
<Paper component="header" className={classes.section} withBorder>
|
||||
<Group justify="space-between" align="start">
|
||||
<div className={classes.identity}>
|
||||
<Avatar src={vm.logoSrc} alt="" size={56} radius="md">
|
||||
{bond.name[0]}
|
||||
</Avatar>
|
||||
<div>
|
||||
<Title order={1} size="h2">
|
||||
{bond.name}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{bond.ticker} · {bond.isin}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="subtle"
|
||||
onClick={() => {
|
||||
vm.bondState.retry();
|
||||
vm.quoteState.retry();
|
||||
vm.metricsState.retry();
|
||||
}}
|
||||
>
|
||||
{t("stock.refreshPrice")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Group mt="md" gap="xs">
|
||||
{bond.floatingCoupon ? (
|
||||
<Badge variant="light">{t("bond.floatingCoupon")}</Badge>
|
||||
) : null}
|
||||
{bond.amortization ? (
|
||||
<Badge variant="light">{t("bond.amortization")}</Badge>
|
||||
) : null}
|
||||
{bond.perpetual ? (
|
||||
<Badge variant="light">{t("bond.perpetual")}</Badge>
|
||||
) : null}
|
||||
{bond.subordinated ? (
|
||||
<Badge variant="light" color="orange">
|
||||
{t("bond.subordinated")}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
{vm.bondState.error || vm.quoteState.error ? (
|
||||
<Alert role="alert" color="orange" mt="md">
|
||||
{t(vm.quote ? "accounts.refreshError" : "stock.sectionError")}
|
||||
</Alert>
|
||||
) : null}
|
||||
{vm.quoteState.loading ? (
|
||||
<LoadingSkeleton label={t("stock.loadingPrice")}>
|
||||
<Skeleton mt="lg" h={55} w="50%" />
|
||||
</LoadingSkeleton>
|
||||
) : (
|
||||
<Stack gap="xs" mt="lg" aria-live="polite">
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("bond.percentOfNominal")}
|
||||
</Text>
|
||||
<Text className={classes.price}>
|
||||
{vm.quote?.pricePercent != null
|
||||
? `${formatDecimal(vm.quote.pricePercent)} %`
|
||||
: t("bond.unknown")}
|
||||
</Text>
|
||||
{vm.quote?.changePercentagePoints != null ? (
|
||||
<Text fw={600}>
|
||||
{formatDecimal(vm.quote.changePercentagePoints)}{" "}
|
||||
{t("bond.percentagePoints")}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed">
|
||||
{vm.quote?.previousCloseDate
|
||||
? t("stock.changeBase", {
|
||||
date: formatDate(vm.quote.previousCloseDate),
|
||||
})
|
||||
: t("stock.noComparison")}
|
||||
</Text>
|
||||
<Group gap="xl" my="sm">
|
||||
<Metric
|
||||
label={t("bond.cleanPrice")}
|
||||
value={formatMoney(vm.quote?.cleanPrice ?? null)}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.aci")}
|
||||
value={formatMoney(vm.quote?.aci ?? null)}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.dirtyPrice")}
|
||||
value={formatMoney(vm.quote?.dirtyPrice ?? null)}
|
||||
/>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bond.indicativePrice")}
|
||||
</Text>
|
||||
<Group>
|
||||
<Badge color="gray" variant="light">
|
||||
{t(`stock.trading.${vm.quote?.tradingStatus}`, {
|
||||
defaultValue: t("stock.trading.unknown"),
|
||||
})}
|
||||
</Badge>
|
||||
{vm.quote?.priceAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.priceAt", {
|
||||
date: formatSnapshotTime(vm.quote.priceAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
{vm.quote?.unavailable.length ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.partialQuote")}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
)}
|
||||
{vm.quoteState.refreshing || vm.bondState.refreshing ? (
|
||||
<Text role="status" size="xs" c="dimmed">
|
||||
{t("accounts.refreshing")}
|
||||
</Text>
|
||||
) : null}
|
||||
<div className={classes.embeddedSection}>
|
||||
<Title order={2} size="h3" mb="md">
|
||||
{t("bond.terms")}
|
||||
</Title>
|
||||
<div className={classes.companyMetrics}>
|
||||
<Metric
|
||||
label={t("bond.nominal")}
|
||||
value={formatMoney(bond.nominal)}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.initialNominal")}
|
||||
value={formatMoney(bond.initialNominal)}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.maturity")}
|
||||
value={
|
||||
bond.perpetual
|
||||
? t("bond.perpetual")
|
||||
: bond.maturityDate
|
||||
? formatDate(bond.maturityDate)
|
||||
: t("bond.unknown")
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.callDate")}
|
||||
value={
|
||||
bond.callDate ? formatDate(bond.callDate) : t("bond.unknown")
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.couponFrequency")}
|
||||
value={
|
||||
bond.couponQuantityPerYear != null
|
||||
? String(bond.couponQuantityPerYear)
|
||||
: t("bond.unknown")
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.lot")}
|
||||
value={bond.lot != null ? String(bond.lot) : t("bond.unknown")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Paper>
|
||||
<InstrumentSection title={t("bond.metrics")} state={vm.metricsState}>
|
||||
{vm.metrics ? (
|
||||
<Stack gap="md">
|
||||
<div className={classes.companyMetrics}>
|
||||
<Metric
|
||||
label={t("bond.creditRating")}
|
||||
value={
|
||||
vm.metrics.creditRating
|
||||
? vm.metrics.creditRating.status === "withdrawn"
|
||||
? t("bond.ratingWithdrawn")
|
||||
: (vm.metrics.creditRating.value ?? t("bond.unknown"))
|
||||
: vm.metrics.unavailable.includes("rating")
|
||||
? t("bond.ratingUnavailable")
|
||||
: t("bond.ratingMissing")
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.couponAmount")}
|
||||
value={formatMoney(vm.metrics.coupon)}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.couponType")}
|
||||
value={t(`bond.couponTypes.${vm.metrics.couponType}`, {
|
||||
defaultValue: t("bond.unknown"),
|
||||
})}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.couponDate")}
|
||||
value={
|
||||
vm.metrics.couponDate
|
||||
? formatDate(vm.metrics.couponDate)
|
||||
: t("bond.unknown")
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.effectiveYield")}
|
||||
value={
|
||||
vm.metrics.effectiveYield
|
||||
? `${formatDecimal(vm.metrics.effectiveYield.value)} %`
|
||||
: t("bond.unknown")
|
||||
}
|
||||
/>
|
||||
<Metric
|
||||
label={t("bond.couponYield")}
|
||||
value={
|
||||
vm.metrics.couponYieldPercent !== null
|
||||
? `${formatDecimal(vm.metrics.couponYieldPercent)} %`
|
||||
: t("bond.unknown")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{vm.metrics.creditRating ? (
|
||||
<Group gap="xs">
|
||||
<Anchor
|
||||
href={vm.metrics.creditRating.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
>
|
||||
{t("bond.ratingSource", {
|
||||
date: formatDate(vm.metrics.creditRating.date),
|
||||
})}
|
||||
</Anchor>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bond.ratingChecked", {
|
||||
date: formatSnapshotTime(
|
||||
vm.metrics.creditRating.observedAt,
|
||||
),
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
) : null}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bond.ratingCoverage")}
|
||||
</Text>
|
||||
{vm.metrics.effectiveYield ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bond.yieldSource", {
|
||||
target: t(
|
||||
`bond.yieldTargets.${vm.metrics.effectiveYield.dateType}`,
|
||||
{ defaultValue: t("bond.yieldTargets.unknown") },
|
||||
),
|
||||
date: formatDate(vm.metrics.effectiveYield.date),
|
||||
price: formatDecimal(vm.metrics.effectiveYield.pricePercent),
|
||||
at: formatSnapshotTime(vm.metrics.effectiveYield.tradeAt),
|
||||
})}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bond.yieldUnavailable")}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bond.couponYieldNote")}
|
||||
</Text>
|
||||
{vm.metrics.couponYieldPriceAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bond.couponYieldPriceAt", {
|
||||
date: formatSnapshotTime(vm.metrics.couponYieldPriceAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
{vm.metrics.unavailable.length ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bond.metricsPartial")}
|
||||
</Text>
|
||||
) : null}
|
||||
</Stack>
|
||||
) : null}
|
||||
</InstrumentSection>
|
||||
<InstrumentChart vm={vm} priceUnit="percent" />
|
||||
<InstrumentPosition
|
||||
vm={vm}
|
||||
returnNote={t("bond.returnNote")}
|
||||
quantityLabel={t("bond.quantity")}
|
||||
/>
|
||||
<InstrumentSection
|
||||
title={t("bond.upcomingCoupons")}
|
||||
state={vm.couponsState}
|
||||
>
|
||||
{vm.coupons ? (
|
||||
<Stack gap="md">
|
||||
{vm.coupons.estimateUnavailable || vm.coupons.positionIsStale ? (
|
||||
<Alert color="orange">{t("stock.estimateUnavailable")}</Alert>
|
||||
) : null}
|
||||
{vm.upcoming.length ? (
|
||||
vm.upcoming.map((coupon) => (
|
||||
<Coupon
|
||||
key={`${coupon.number}:${coupon.paymentDate}`}
|
||||
coupon={coupon}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text c="dimmed">{t("bond.noCoupons")}</Text>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bond.couponWindow", {
|
||||
from: formatDate(vm.coupons.from),
|
||||
to: formatDate(vm.coupons.to),
|
||||
})}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bond.couponNote")}
|
||||
</Text>
|
||||
{vm.coupons.positionAt ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.snapshotAt", {
|
||||
date: formatSnapshotTime(vm.coupons.positionAt),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
<Accordion variant="contained" radius="md">
|
||||
<Accordion.Item value="history">
|
||||
<Accordion.Control>{t("bond.couponHistory")}</Accordion.Control>
|
||||
<Accordion.Panel>
|
||||
<Stack gap="sm">
|
||||
{vm.pastCoupons.length ? (
|
||||
vm.pastCoupons.map((coupon) => (
|
||||
<Coupon
|
||||
key={`${coupon.number}:${coupon.paymentDate}`}
|
||||
coupon={coupon}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Text c="dimmed">{t("bond.noCoupons")}</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
</Accordion>
|
||||
</Stack>
|
||||
) : null}
|
||||
</InstrumentSection>
|
||||
<InstrumentEvents
|
||||
vm={vm}
|
||||
incomeLabel={t("bond.coupons")}
|
||||
incomeFilter="coupons"
|
||||
historyNote={t("bond.historyNote")}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
type MetricProps = { label: string; value: string };
|
||||
const Metric: FC<MetricProps> = (props) => (
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">
|
||||
{props.label}
|
||||
</Text>
|
||||
<Text fw={600} size="lg">
|
||||
{props.value}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
type CouponProps = {
|
||||
coupon: NonNullable<BondPageVm["coupons"]>["coupons"][number];
|
||||
};
|
||||
const Coupon: FC<CouponProps> = (props) => {
|
||||
const { coupon } = props;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Paper withBorder p="md" radius="md">
|
||||
<Group justify="space-between" align="start">
|
||||
<div>
|
||||
<Text fw={600}>
|
||||
{coupon.paymentDate
|
||||
? formatDate(coupon.paymentDate)
|
||||
: t("stock.dateUnknown")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bond.couponNumber", {
|
||||
number: coupon.number ?? t("bond.unknown"),
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
<Metric label={t("bond.perBond")} value={formatMoney(coupon.perBond)} />
|
||||
{coupon.estimatedAmount ? (
|
||||
<Metric
|
||||
label={t("stock.yourEstimate")}
|
||||
value={formatMoney(coupon.estimatedAmount)}
|
||||
/>
|
||||
) : null}
|
||||
</Group>
|
||||
<Group mt="sm" gap="md">
|
||||
<Badge variant="light" color="gray">
|
||||
{t(`bond.couponTypes.${coupon.couponType}`, {
|
||||
defaultValue: t("bond.unknown"),
|
||||
})}
|
||||
</Badge>
|
||||
{coupon.periodDays != null ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("bond.couponDays", { count: coupon.periodDays })}
|
||||
</Text>
|
||||
) : null}
|
||||
{coupon.recordDate ? (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("stock.record")}: {formatDate(coupon.recordDate)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { BondPageView };
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useParams, useSearchParams } from "react-router";
|
||||
import {
|
||||
type BondPeriod,
|
||||
useBond,
|
||||
useBondCandles,
|
||||
useBondCoupons,
|
||||
useBondMetrics,
|
||||
useBondPosition,
|
||||
useBondQuote,
|
||||
} from "@/entities/bond";
|
||||
import {
|
||||
brokerageOperationsQueryOptions,
|
||||
type OperationCategory,
|
||||
useBrokerageOperations,
|
||||
} from "@/entities/broker-operation";
|
||||
import { section } from "@/features/instrument-details";
|
||||
import { apiErrorCode } from "@/shared/api";
|
||||
import { formatDate, instrumentLogoUrl } from "@/shared/lib";
|
||||
|
||||
const periods: BondPeriod[] = ["month", "halfYear", "year", "all"];
|
||||
const filters = ["all", "trades", "coupons", "other"] as const;
|
||||
type BondEventFilter = (typeof filters)[number];
|
||||
const categories: Record<BondEventFilter, OperationCategory[]> = {
|
||||
all: [],
|
||||
trades: ["trade"],
|
||||
coupons: ["income"],
|
||||
other: ["tax", "fee", "transfer", "other"],
|
||||
};
|
||||
function useBondPageVm() {
|
||||
const { bondId = "" } = useParams();
|
||||
const bond = useBond(bondId);
|
||||
const uid = bond.data?.instrumentUid ?? "";
|
||||
const [params, setParams] = useSearchParams();
|
||||
const period =
|
||||
periods.find((value) => value === params.get("period")) ?? "month";
|
||||
const filter =
|
||||
filters.find((value) => value === params.get("events")) ?? "all";
|
||||
const scope = `${uid}:${filter}`;
|
||||
const [pagination, setPagination] = useState<{
|
||||
scope: string;
|
||||
cursors: string[];
|
||||
}>({ scope, cursors: [] });
|
||||
const cursors = pagination.scope === scope ? pagination.cursors : [];
|
||||
const client = useQueryClient();
|
||||
const quote = useBondQuote(uid);
|
||||
const metrics = useBondMetrics(uid);
|
||||
const candles = useBondCandles(uid, period);
|
||||
const position = useBondPosition(uid);
|
||||
const coupons = useBondCoupons(uid);
|
||||
const firstPage = {
|
||||
instrumentUid: uid,
|
||||
categories: categories[filter],
|
||||
limit: 20,
|
||||
};
|
||||
const operations = useBrokerageOperations(uid ? "all" : undefined, {
|
||||
...firstPage,
|
||||
cursor: cursors.at(-1),
|
||||
});
|
||||
const setParam = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set(key, value);
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
const retryOperations = () => {
|
||||
if (apiErrorCode(operations.error) === "INVALID_OPERATIONS_CURSOR") {
|
||||
setPagination({ scope, cursors: [] });
|
||||
void client.invalidateQueries({
|
||||
queryKey: brokerageOperationsQueryOptions("all", firstPage).queryKey,
|
||||
exact: true,
|
||||
});
|
||||
} else void operations.refetch();
|
||||
};
|
||||
const chartData =
|
||||
candles.data?.candles.map((candle) => ({
|
||||
...candle,
|
||||
date: formatDate(candle.time),
|
||||
price: Number(candle.close),
|
||||
})) ?? [];
|
||||
return {
|
||||
canonicalTo:
|
||||
bond.data?.slug && bond.data.slug !== bondId
|
||||
? `/bonds/${encodeURIComponent(bond.data.slug)}${params.size ? `?${params.toString()}` : ""}`
|
||||
: null,
|
||||
bond: bond.data,
|
||||
bondState: section(bond),
|
||||
code: apiErrorCode(bond.error),
|
||||
logoSrc: instrumentLogoUrl(bond.data?.logoName),
|
||||
metrics: metrics.data,
|
||||
metricsState: section(metrics),
|
||||
quote: quote.data,
|
||||
quoteState: section(quote),
|
||||
candles: candles.data,
|
||||
chartData,
|
||||
priceRows: chartData.toReversed(),
|
||||
candlesState: section(candles),
|
||||
period,
|
||||
setPeriod: (value: string) => setParam("period", value),
|
||||
averageLine: null,
|
||||
hasIncompleteCandle:
|
||||
candles.data?.candles.some((candle) => !candle.isComplete) ?? false,
|
||||
position: position.data,
|
||||
positionState: section(position),
|
||||
coupons: coupons.data,
|
||||
couponsState: section(coupons),
|
||||
upcoming:
|
||||
coupons.data?.coupons
|
||||
.filter((event) => event.timing === "upcoming")
|
||||
.sort((a, b) =>
|
||||
(a.paymentDate ?? a.recordDate ?? "").localeCompare(
|
||||
b.paymentDate ?? b.recordDate ?? "",
|
||||
),
|
||||
) ?? [],
|
||||
pastCoupons:
|
||||
coupons.data?.coupons
|
||||
.filter((event) => event.timing !== "upcoming")
|
||||
.sort((a, b) =>
|
||||
(b.paymentDate ?? b.recordDate ?? "").localeCompare(
|
||||
a.paymentDate ?? a.recordDate ?? "",
|
||||
),
|
||||
) ?? [],
|
||||
operations: operations.data,
|
||||
operationsState: { ...section(operations), retry: retryOperations },
|
||||
eventRows:
|
||||
operations.data?.items.map((item) => ({
|
||||
...item,
|
||||
ticker: item.ticker ?? bond.data?.ticker ?? null,
|
||||
logoName: item.logoName ?? bond.data?.logoName ?? null,
|
||||
})) ?? [],
|
||||
filter,
|
||||
setFilter: (value: string) => setParam("events", value),
|
||||
hasPrevious: cursors.length > 0,
|
||||
onPrevious: () => setPagination({ scope, cursors: cursors.slice(0, -1) }),
|
||||
onNext: () => {
|
||||
if (operations.data?.nextCursor)
|
||||
setPagination({
|
||||
scope,
|
||||
cursors: [...cursors, operations.data.nextCursor],
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
type BondPageVm = ReturnType<typeof useBondPageVm>;
|
||||
|
||||
export { type BondPageVm, useBondPageVm };
|
||||
Reference in New Issue
Block a user