diff --git a/src/app/routes/AppRoutes.tsx b/src/app/routes/AppRoutes.tsx index 0a77f7d..44c156e 100644 --- a/src/app/routes/AppRoutes.tsx +++ b/src/app/routes/AppRoutes.tsx @@ -4,6 +4,14 @@ import { Navigate, Route, Routes } from "react-router"; import { LazyPage } from "./RouteFallback"; import { Protected, Public } from "./RouteGuards"; +const BondPage = lazy(async () => { + const { BondPage } = await import("@/pages/bond"); + return { default: BondPage }; +}); +const BondsPage = lazy(async () => { + const { BondsPage } = await import("@/pages/bonds"); + return { default: BondsPage }; +}); const StockPage = lazy(async () => { const { StockPage } = await import("@/pages/stock"); return { default: StockPage }; @@ -85,6 +93,22 @@ const AppRoutes: FC = () => ( } > + + + + } + /> + + + + } + /> { const settings = useMatch({ path: "/settings", end: true }); const stock = useMatch({ path: "/stocks/:stockId", end: true }); + const bond = useMatch({ path: "/bonds/:bondId", end: true }); + if (bond) return ; if (stock) return ; if (operations) diff --git a/src/app/ui/BondBreadcrumbs.tsx b/src/app/ui/BondBreadcrumbs.tsx new file mode 100644 index 0000000..e8075b8 --- /dev/null +++ b/src/app/ui/BondBreadcrumbs.tsx @@ -0,0 +1,20 @@ +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { useBond } from "@/entities/bond"; +import { BreadcrumbTrail } from "./BreadcrumbTrail"; + +type BondBreadcrumbsProps = { identifier: string }; +const BondBreadcrumbs: FC = (props) => { + const { data } = useBond(props.identifier); + const { t } = useTranslation(); + return ( + + ); +}; + +export { BondBreadcrumbs }; diff --git a/src/entities/bond/api/queries.ts b/src/entities/bond/api/queries.ts new file mode 100644 index 0000000..9c153d1 --- /dev/null +++ b/src/entities/bond/api/queries.ts @@ -0,0 +1,97 @@ +import { apiQueryClient, useAuthenticatedQuery } from "@/shared/api"; + +type BondPeriod = "month" | "halfYear" | "year" | "all"; + +function useBondSearch(query: string) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bonds", { + params: { query: { query } }, + }), + enabled: query.length >= 2, + staleTime: 30_000, + }); +} +function bondQueryOptions(uid: string) { + return { + ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}", { + params: { path: { uid } }, + }), + staleTime: 30_000, + }; +} + +function useBond(identifier: string) { + const isUid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + identifier, + ); + + const byUid = useAuthenticatedQuery({ + ...bondQueryOptions(identifier), + enabled: Boolean(identifier) && isUid, + }); + const byTicker = useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bonds/by-ticker/{ticker}", { + params: { path: { ticker: identifier } }, + }), + enabled: Boolean(identifier) && !isUid, + staleTime: 30_000, + }); + return isUid ? byUid : byTicker; +} +function useBondMetrics(uid: string) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/metrics", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: 30_000, + }); +} +function useBondQuote(uid: string) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/quote", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: 30_000, + }); +} +function useBondCandles(uid: string, period: BondPeriod) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/candles", { + params: { path: { uid }, query: { period } }, + }), + enabled: Boolean(uid), + staleTime: 30_000, + }); +} +function useBondPosition(uid: string) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/position", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: 30_000, + }); +} +function useBondCoupons(uid: string) { + return useAuthenticatedQuery({ + ...apiQueryClient.queryOptions("get", "/api/v1/bonds/{uid}/coupons", { + params: { path: { uid } }, + }), + enabled: Boolean(uid), + staleTime: 30_000, + }); +} + +export { + type BondPeriod, + useBond, + useBondCandles, + useBondCoupons, + useBondMetrics, + useBondPosition, + useBondQuote, + useBondSearch, +}; diff --git a/src/entities/bond/index.ts b/src/entities/bond/index.ts new file mode 100644 index 0000000..f03ebed --- /dev/null +++ b/src/entities/bond/index.ts @@ -0,0 +1,10 @@ +export { + type BondPeriod, + useBond, + useBondCandles, + useBondCoupons, + useBondMetrics, + useBondPosition, + useBondQuote, + useBondSearch, +} from "./api/queries"; diff --git a/src/features/instrument-details/index.ts b/src/features/instrument-details/index.ts new file mode 100644 index 0000000..dfaf39b --- /dev/null +++ b/src/features/instrument-details/index.ts @@ -0,0 +1,5 @@ +export { section } from "./model/section"; +export { InstrumentChart } from "./ui/InstrumentChart.view"; +export { InstrumentEvents } from "./ui/InstrumentEvents.view"; +export { InstrumentPosition } from "./ui/InstrumentPosition.view"; +export { InstrumentSection } from "./ui/InstrumentSection.view"; diff --git a/src/features/instrument-details/model/section.ts b/src/features/instrument-details/model/section.ts new file mode 100644 index 0000000..972d7e6 --- /dev/null +++ b/src/features/instrument-details/model/section.ts @@ -0,0 +1,25 @@ +type SectionState = { + hasData: boolean; + loading: boolean; + error: boolean; + refreshing: boolean; + retry: () => void; +}; +function section(query: { + data: unknown; + isError: boolean; + isFetching: boolean; + refetch: () => unknown; +}): SectionState { + return { + hasData: Boolean(query.data), + loading: !query.data && !query.isError, + error: query.isError, + refreshing: Boolean(query.data) && query.isFetching, + retry: () => { + void query.refetch(); + }, + }; +} + +export { type SectionState, section }; diff --git a/src/features/instrument-details/model/types.ts b/src/features/instrument-details/model/types.ts new file mode 100644 index 0000000..ccfa9b1 --- /dev/null +++ b/src/features/instrument-details/model/types.ts @@ -0,0 +1,36 @@ +import type { BrokerageOperation } from "@/entities/broker-operation"; +import type { paths } from "@/shared/api"; +import type { SectionState } from "./section"; + +type Position = + paths["/api/v1/stocks/{uid}/position"]["get"]["responses"][200]["content"]["application/json"]; +type Candles = + paths["/api/v1/stocks/{uid}/candles"]["get"]["responses"][200]["content"]["application/json"]; +type ChartRow = Candles["candles"][number] & { date: string; price: number }; +type InstrumentPositionVm = { + position?: Position; + positionState: SectionState; +}; +type InstrumentChartVm = { + candles?: Candles; + candlesState: SectionState; + chartData: ChartRow[]; + priceRows: ChartRow[]; + period: string; + setPeriod: (value: string) => void; + averageLine: number | null; + hasIncompleteCandle: boolean; + position?: Position; +}; +type InstrumentEventsVm = { + operations?: { hasNext: boolean }; + operationsState: SectionState; + filter: string; + setFilter: (value: string) => void; + eventRows: BrokerageOperation[]; + hasPrevious: boolean; + onPrevious: () => void; + onNext: () => void; +}; + +export type { InstrumentChartVm, InstrumentEventsVm, InstrumentPositionVm }; diff --git a/src/pages/stock/ui/components/StockChart.view.tsx b/src/features/instrument-details/ui/InstrumentChart.view.tsx similarity index 76% rename from src/pages/stock/ui/components/StockChart.view.tsx rename to src/features/instrument-details/ui/InstrumentChart.view.tsx index d5ed298..0e1a7e0 100644 --- a/src/pages/stock/ui/components/StockChart.view.tsx +++ b/src/features/instrument-details/ui/InstrumentChart.view.tsx @@ -9,17 +9,17 @@ import { } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { formatMoney } from "@/shared/lib"; -import classes from "../StockPage.module.css"; -import type { StockPageVm } from "../StockPage.vm"; -import { StockSection } from "./StockSection.view"; +import { formatDecimal, formatMoney } from "@/shared/lib"; +import type { InstrumentChartVm } from "../model/types"; +import classes from "./InstrumentDetails.module.css"; +import { InstrumentSection } from "./InstrumentSection.view"; -type StockChartProps = { vm: StockPageVm }; -const StockChart: FC = (props) => { +type InstrumentChartProps = { vm: InstrumentChartVm; priceUnit?: "percent" }; +const InstrumentChart: FC = (props) => { const { vm } = props; const { t } = useTranslation(); return ( - = (props) => { maximumFractionDigits: 4, }).format(value) } - aria-label={t("stock.chartLabel", { - currency: vm.candles.currency ?? "", - })} + aria-label={t( + props.priceUnit === "percent" + ? "bond.chartLabel" + : "stock.chartLabel", + { + currency: + props.priceUnit === "percent" + ? t("bond.percentOfNominal") + : (vm.candles.currency ?? ""), + }, + )} /> - {t("stock.chartNote")} + {t( + props.priceUnit === "percent" + ? "bond.chartNote" + : "stock.chartNote", + )} {vm.hasIncompleteCandle ? ( @@ -131,14 +143,16 @@ const StockChart: FC = (props) => { {candle.date} - {formatMoney( - vm.candles?.currency - ? { - value: candle.close, - currency: vm.candles.currency, - } - : null, - )} + {props.priceUnit === "percent" + ? `${formatDecimal(candle.close)} %` + : formatMoney( + vm.candles?.currency + ? { + value: candle.close, + currency: vm.candles.currency, + } + : null, + )} = (props) => { {t("stock.noCandles")} ) ) : null} - + ); }; -export { StockChart }; +export { InstrumentChart }; diff --git a/src/features/instrument-details/ui/InstrumentDetails.module.css b/src/features/instrument-details/ui/InstrumentDetails.module.css new file mode 100644 index 0000000..5947dc0 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentDetails.module.css @@ -0,0 +1,62 @@ +.section { + padding: var(--mantine-spacing-lg); + border-radius: var(--mantine-radius-lg); + min-width: 0; +} +.metrics { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: var(--mantine-spacing-lg); +} +.metricValue { + font-variant-numeric: tabular-nums; + font-size: var(--mantine-font-size-xl); + font-weight: 600; + overflow-wrap: anywhere; +} +.holding { + background: var(--mantine-color-blue-0); + padding: var(--mantine-spacing-lg); + border-radius: var(--mantine-radius-md); +} +.accountRow { + display: grid; + grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr)); + gap: var(--mantine-spacing-sm); + padding: var(--mantine-spacing-md) 0; + border-top: 1px solid var(--wl-color-border); + align-items: start; +} +.controls { + max-width: 100%; + overflow-x: auto; +} +.embeddedSection { + margin-top: var(--mantine-spacing-lg); + padding-top: var(--mantine-spacing-lg); + border-top: 1px solid var(--wl-color-border); +} +.priceTable { + margin-top: var(--mantine-spacing-md); +} +.priceTable table { + min-width: 430px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.priceTable th:nth-child(2), +.priceTable td:nth-child(2) { + text-align: right; +} +@media (max-width: 48em) { + .section { + padding: var(--mantine-spacing-md); + } + .metrics, + .accountRow { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .accountRow > :first-child { + grid-column: 1 / -1; + } +} diff --git a/src/features/instrument-details/ui/InstrumentEvents.view.tsx b/src/features/instrument-details/ui/InstrumentEvents.view.tsx new file mode 100644 index 0000000..5d7f873 --- /dev/null +++ b/src/features/instrument-details/ui/InstrumentEvents.view.tsx @@ -0,0 +1,88 @@ +import { Button, Group, SegmentedControl, Stack, Text } from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { LedgerEntryRow } from "@/entities/broker-operation"; +import type { InstrumentEventsVm } from "../model/types"; +import classes from "./InstrumentDetails.module.css"; +import { InstrumentSection } from "./InstrumentSection.view"; + +type InstrumentEventsProps = { + vm: InstrumentEventsVm; + incomeLabel?: string; + incomeFilter?: string; + historyNote?: string; +}; + +const InstrumentEvents: FC = (props) => { + const { vm } = props; + const { t } = useTranslation(); + return ( + + ({ + value, + label: + value === (props.incomeFilter ?? "dividends") && + props.incomeLabel + ? props.incomeLabel + : t(`stock.filters.${value}`), + }))} + /> + + } + > + {vm.operations ? ( + <> + + {props.historyNote ?? t("stock.historyNote")} + + {vm.eventRows.length ? ( + + {vm.eventRows.map((event) => ( + + ))} + + ) : ( + + {t("stock.noOperations")} + + )} + + + + + + ) : null} + + ); +}; + +export { InstrumentEvents }; diff --git a/src/pages/stock/ui/components/StockPosition.view.tsx b/src/features/instrument-details/ui/InstrumentPosition.view.tsx similarity index 84% rename from src/pages/stock/ui/components/StockPosition.view.tsx rename to src/features/instrument-details/ui/InstrumentPosition.view.tsx index ede7bae..ee23cd5 100644 --- a/src/pages/stock/ui/components/StockPosition.view.tsx +++ b/src/features/instrument-details/ui/InstrumentPosition.view.tsx @@ -3,17 +3,21 @@ import type { FC } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router"; import { formatDecimal, formatMoney, formatSnapshotTime } from "@/shared/lib"; -import classes from "../StockPage.module.css"; -import type { StockPageVm } from "../StockPage.vm"; -import { StockSection } from "./StockSection.view"; +import type { InstrumentPositionVm } from "../model/types"; +import classes from "./InstrumentDetails.module.css"; +import { InstrumentSection } from "./InstrumentSection.view"; -type StockPositionProps = { vm: StockPageVm }; -const StockPosition: FC = (props) => { +type InstrumentPositionProps = { + vm: InstrumentPositionVm; + returnNote?: string; + quantityLabel?: string; +}; +const InstrumentPosition: FC = (props) => { const { vm } = props; const { t } = useTranslation(); if (vm.position && !vm.position.accounts.length) return null; return ( - + {vm.position ? ( {vm.position.isStale ? ( @@ -22,7 +26,7 @@ const StockPosition: FC = (props) => {
- {t("stock.quantity")} + {props.quantityLabel ?? t("stock.quantity")} {formatDecimal(vm.position.quantity)} @@ -66,7 +70,7 @@ const StockPosition: FC = (props) => {
- {t("stock.openReturnNote")} + {props.returnNote ?? t("stock.openReturnNote")}
{vm.position.accounts.map((account) => ( @@ -83,7 +87,7 @@ const StockPosition: FC = (props) => { )}
- {t("stock.quantity")} + {props.quantityLabel ?? t("stock.quantity")} {formatDecimal(account.quantity)}
@@ -128,8 +132,8 @@ const StockPosition: FC = (props) => { ) : null} - + ); }; -export { StockPosition }; +export { InstrumentPosition }; diff --git a/src/pages/stock/ui/components/StockSection.view.tsx b/src/features/instrument-details/ui/InstrumentSection.view.tsx similarity index 85% rename from src/pages/stock/ui/components/StockSection.view.tsx rename to src/features/instrument-details/ui/InstrumentSection.view.tsx index cb60ee1..41a1c1d 100644 --- a/src/pages/stock/ui/components/StockSection.view.tsx +++ b/src/features/instrument-details/ui/InstrumentSection.view.tsx @@ -11,17 +11,17 @@ import { import type { FC, PropsWithChildren, ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { LoadingSkeleton } from "@/shared/ui"; -import classes from "../StockPage.module.css"; -import type { SectionState } from "../StockPage.vm"; +import type { SectionState } from "../model/section"; +import classes from "./InstrumentDetails.module.css"; -type StockSectionProps = PropsWithChildren<{ +type InstrumentSectionProps = PropsWithChildren<{ title: string; state: SectionState; controls?: ReactNode; large?: boolean; embedded?: boolean; }>; -const StockSection: FC = (props) => { +const InstrumentSection: FC = (props) => { const { t } = useTranslation(); return ( = (props) => { ); }; -export { StockSection }; +export { InstrumentSection }; diff --git a/src/features/tbank-connection/model/connection-boundary.ts b/src/features/tbank-connection/model/connection-boundary.ts index e8cb281..440864b 100644 --- a/src/features/tbank-connection/model/connection-boundary.ts +++ b/src/features/tbank-connection/model/connection-boundary.ts @@ -22,7 +22,9 @@ function commitTbankConnectionBoundary( return ( method === "get" && typeof path === "string" && - (path === "/api/v1/stocks" || + (path === "/api/v1/bonds" || + path.startsWith("/api/v1/bonds/") || + path === "/api/v1/stocks" || path.startsWith("/api/v1/stocks/") || path === brokerageAccountsPath || path.startsWith(`${brokerageAccountsPath}/`)) diff --git a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx index df4bec5..71c4f3c 100644 --- a/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx +++ b/src/features/tbank-connection/ui/TbankConnectionSection.test.tsx @@ -27,6 +27,11 @@ const stockQueryKey = apiQueryClient.queryOptions( "/api/v1/stocks/{uid}/position", { params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } }, ).queryKey; +const bondQueryKey = apiQueryClient.queryOptions( + "get", + "/api/v1/bonds/{uid}/position", + { params: { path: { uid: "e6123145-9665-43e0-8413-cd61b8aa9b13" } } }, +).queryKey; const unrelatedQueryKey = apiQueryClient.queryOptions( "get", "/api/v1/auth/me", @@ -59,6 +64,7 @@ function seededClient() { ]); client.setQueryData(portfolioQueryKey, { source: "previous-connection" }); client.setQueryData(stockQueryKey, { source: "previous-connection" }); + client.setQueryData(bondQueryKey, { source: "previous-connection" }); client.setQueryData(unrelatedQueryKey, { id: "user-1" }); return client; } @@ -82,6 +88,7 @@ describe("TbankConnectionSection", () => { expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); expect(client.getQueryData(stockQueryKey)).toBeUndefined(); + expect(client.getQueryData(bondQueryKey)).toBeUndefined(); expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" }); }); @@ -134,6 +141,7 @@ describe("TbankConnectionSection", () => { expect(client.getQueryData(brokerageAccountsQueryKey)).toBeUndefined(); expect(client.getQueryData(portfolioQueryKey)).toBeUndefined(); expect(client.getQueryData(stockQueryKey)).toBeUndefined(); + expect(client.getQueryData(bondQueryKey)).toBeUndefined(); expect(client.getQueryData(unrelatedQueryKey)).toEqual({ id: "user-1" }); }); diff --git a/src/pages/account/ui/AccountPage.test.tsx b/src/pages/account/ui/AccountPage.test.tsx index 2480089..b566fce 100644 --- a/src/pages/account/ui/AccountPage.test.tsx +++ b/src/pages/account/ui/AccountPage.test.tsx @@ -121,15 +121,22 @@ function emptyProps() { } describe("AccountPage states", () => { - it.each(["account-1", "all"])( - "uses verified ticker hrefs for both name and logo in %s", - async (accountId) => { + it.each( + ["account-1", "all"].flatMap((accountId) => + ["STOCK", "BOND"].map((type) => ({ accountId, type })), + ), + )( + "links name and logo to the exact issue in $accountId for $type", + async ({ accountId, type }) => { + const collection = type === "STOCK" ? "stocks" : "bonds"; + const slug = type === "STOCK" ? "SBERP" : "SU26238RMFS4"; const uid = "c190ff1f-1447-4227-b543-316332699ca5"; const requests = vi.fn(); const position = { ...portfolio.positions[0], + type, instrumentUid: uid, - ticker: "SBERP", + ticker: slug, displayName: "Сбербанк привилегированные", }; server.use( @@ -145,9 +152,9 @@ describe("AccountPage states", () => { : [position], }), ), - http.get("*/api/v1/stocks/:uid", () => { + http.get(`*/api/v1/${collection}/:uid`, () => { requests(); - return HttpResponse.json({ instrumentUid: uid, slug: "SBERP" }); + return HttpResponse.json({ instrumentUid: uid, slug }); }), ); configureTestAuth(); @@ -158,9 +165,12 @@ describe("AccountPage states", () => { }); expect(links).toHaveLength(2); for (const link of links) - expect(link).toHaveAttribute("href", "/stocks/SBERP"); + expect(link).toHaveAttribute( + "href", + `/${collection}/${type === "STOCK" ? slug : uid}`, + ); }); - expect(requests).toHaveBeenCalledOnce(); + expect(requests).toHaveBeenCalledTimes(type === "STOCK" ? 1 : 0); }, ); diff --git a/src/pages/account/ui/AccountPage.vm.ts b/src/pages/account/ui/AccountPage.vm.ts index 919de56..4064199 100644 --- a/src/pages/account/ui/AccountPage.vm.ts +++ b/src/pages/account/ui/AccountPage.vm.ts @@ -25,7 +25,7 @@ type AccountPagePosition = Portfolio["positions"][number] & { returnText: string | null; returnTone?: AccountPageReturnTone; logoSrc: string | null; - stockTo?: string; + instrumentTo?: string; }; type AccountPagePositionGroup = { @@ -87,13 +87,14 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { const { accountId: routeAccountId } = useParams(); const accountId = scopeId ?? routeAccountId; const portfolio = usePortfolio(accountId); - const stockDetails = useStocks( + const stocks = useStocks( portfolio.data?.positions.flatMap((position) => position.type === "STOCK" && position.instrumentUid ? [position.instrumentUid] : [], ) ?? [], ); + const instrumentDetails = stocks; const operations = useBrokerageOperations(accountId, { limit: 5, }); @@ -136,7 +137,7 @@ function useAccountPageViewModel(scopeId?: string): AccountPageViewModel { portfolio: portfolio.data, positionSections: toAccountPagePositionSections( portfolio.data, - stockDetails, + instrumentDetails, ), recentOperations, recentEvents, @@ -182,7 +183,7 @@ function toRecentEvents( function toAccountPagePositionSections( portfolio: Portfolio, - stockDetails: ReturnType = new Map(), + instrumentDetails: Map = new Map(), ): AccountPagePositionSection[] { const denominators = new Map(); for (const position of portfolio.positions) { @@ -193,9 +194,14 @@ function toAccountPagePositionSections( (denominators.get(currency) ?? 0) + Number(position.currentValue.value), ); } - const stocks = toSection("stocks", portfolio, denominators, stockDetails); - const bonds = toSection("bonds", portfolio, denominators, stockDetails); - const other = toSection("other", portfolio, denominators, stockDetails); + const stocks = toSection( + "stocks", + portfolio, + denominators, + instrumentDetails, + ); + const bonds = toSection("bonds", portfolio, denominators, instrumentDetails); + const other = toSection("other", portfolio, denominators, instrumentDetails); return [stocks, bonds, other].filter((section) => section.groups.length > 0); } @@ -203,7 +209,7 @@ function toSection( kind: PositionSectionKind, portfolio: Portfolio, denominators: Map, - stockDetails: ReturnType, + instrumentDetails: Map, ): AccountPagePositionSection { return { kind, @@ -223,7 +229,7 @@ function toSection( position, denominators, kind, - stockDetails.get(position.instrumentUid ?? "")?.slug, + instrumentDetails.get(position.instrumentUid ?? "")?.slug, ), ), ), @@ -265,7 +271,7 @@ function toPosition( position: Portfolio["positions"][number], denominators: Map, kind: PositionSectionKind, - stockSlug: string | undefined, + instrumentSlug: string | undefined, ): AccountPagePosition { const expanded = kind !== "other"; const returnValue = position.absoluteReturn @@ -279,9 +285,9 @@ function toPosition( : "negative"; return { ...position, - stockTo: - kind === "stocks" && position.instrumentUid - ? `/stocks/${encodeURIComponent(stockSlug ?? position.instrumentUid)}` + instrumentTo: + (kind === "stocks" || kind === "bonds") && position.instrumentUid + ? `/${kind}/${encodeURIComponent(instrumentSlug ?? position.instrumentUid)}` : undefined, shareText: position.currentValue?.currency ? formatShare(position.currentValue, { diff --git a/src/pages/account/ui/components/PositionCard.view.tsx b/src/pages/account/ui/components/PositionCard.view.tsx index c547fe6..7505478 100644 --- a/src/pages/account/ui/components/PositionCard.view.tsx +++ b/src/pages/account/ui/components/PositionCard.view.tsx @@ -30,7 +30,7 @@ const PositionCard: FC = (props) => { value: position.returnText, }) : null; - const stockTo = position.stockTo; + const instrumentTo = position.instrumentTo; const identifier = kind === "stocks" ? (position.ticker ?? position.isin) : position.isin; const identifierText = [ @@ -69,11 +69,11 @@ const PositionCard: FC = (props) => { key={position.logoSrc ?? "no-logo"} src={position.logoSrc} label={position.displayName} - to={stockTo} + to={instrumentTo} /> - {stockTo ? ( - + {instrumentTo ? ( + {position.displayName} ) : ( diff --git a/src/pages/account/ui/components/PositionGroupCard.view.tsx b/src/pages/account/ui/components/PositionGroupCard.view.tsx index d08bb0f..66a80d8 100644 --- a/src/pages/account/ui/components/PositionGroupCard.view.tsx +++ b/src/pages/account/ui/components/PositionGroupCard.view.tsx @@ -19,7 +19,7 @@ const PositionGroupCard: FC = (props) => { const { group, kind } = props; const { t } = useTranslation(); const identity = group.positions[0]; - const stockTo = identity.stockTo; + const instrumentTo = identity.instrumentTo; return ( @@ -28,12 +28,12 @@ const PositionGroupCard: FC = (props) => { key={identity.logoSrc ?? "no-logo"} src={identity.logoSrc} label={identity.displayName} - to={stockTo} + to={instrumentTo} /> ) : null} - {stockTo ? ( - + {instrumentTo ? ( + {identity.displayName} ) : ( diff --git a/src/pages/bond/index.ts b/src/pages/bond/index.ts new file mode 100644 index 0000000..5b92960 --- /dev/null +++ b/src/pages/bond/index.ts @@ -0,0 +1 @@ +export { BondPage } from "./ui/BondPage"; diff --git a/src/pages/bond/ui/BondPage.module.css b/src/pages/bond/ui/BondPage.module.css new file mode 100644 index 0000000..51c9498 --- /dev/null +++ b/src/pages/bond/ui/BondPage.module.css @@ -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)); + } +} diff --git a/src/pages/bond/ui/BondPage.test.tsx b/src/pages/bond/ui/BondPage.test.tsx new file mode 100644 index 0000000..7f5976d --- /dev/null +++ b/src/pages/bond/ui/BondPage.test.tsx @@ -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 ( + + {location.pathname} + {location.search} + + ); +}; + +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( + + + + + + } /> + + + + , + ); + 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), + ); + }); +}); diff --git a/src/pages/bond/ui/BondPage.tsx b/src/pages/bond/ui/BondPage.tsx new file mode 100644 index 0000000..63e0047 --- /dev/null +++ b/src/pages/bond/ui/BondPage.tsx @@ -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 ; + if (vm.bondState.loading) + return ( + + + + + + + + ); + if (!vm.bond) + return ( + + {t("bond.unavailable")} + + {t( + vm.code === "TBANK_NOT_CONNECTED" || + vm.code === "INVALID_TBANK_TOKEN" + ? "bond.connect" + : vm.code === "BOND_TICKER_AMBIGUOUS" + ? "bond.ambiguousTicker" + : "bond.unavailableText", + )} + + + {t("bond.backToSearch")} + + + {t("accounts.openSettings")} + + + + ); + return ; +}; + +export { BondPage }; diff --git a/src/pages/bond/ui/BondPage.view.tsx b/src/pages/bond/ui/BondPage.view.tsx new file mode 100644 index 0000000..d194c8f --- /dev/null +++ b/src/pages/bond/ui/BondPage.view.tsx @@ -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 = (props) => { + const { vm } = props; + const { t } = useTranslation(); + const bond = vm.bond; + if (!bond) return null; + return ( + + + +
+ + {bond.name[0]} + +
+ + {bond.name} + + + {bond.ticker} · {bond.isin} + +
+
+ +
+ + {bond.floatingCoupon ? ( + {t("bond.floatingCoupon")} + ) : null} + {bond.amortization ? ( + {t("bond.amortization")} + ) : null} + {bond.perpetual ? ( + {t("bond.perpetual")} + ) : null} + {bond.subordinated ? ( + + {t("bond.subordinated")} + + ) : null} + + {vm.bondState.error || vm.quoteState.error ? ( + + {t(vm.quote ? "accounts.refreshError" : "stock.sectionError")} + + ) : null} + {vm.quoteState.loading ? ( + + + + ) : ( + + + {t("bond.percentOfNominal")} + + + {vm.quote?.pricePercent != null + ? `${formatDecimal(vm.quote.pricePercent)} %` + : t("bond.unknown")} + + {vm.quote?.changePercentagePoints != null ? ( + + {formatDecimal(vm.quote.changePercentagePoints)}{" "} + {t("bond.percentagePoints")} + + ) : null} + + {vm.quote?.previousCloseDate + ? t("stock.changeBase", { + date: formatDate(vm.quote.previousCloseDate), + }) + : t("stock.noComparison")} + + + + + + + + {t("bond.indicativePrice")} + + + + {t(`stock.trading.${vm.quote?.tradingStatus}`, { + defaultValue: t("stock.trading.unknown"), + })} + + {vm.quote?.priceAt ? ( + + {t("stock.priceAt", { + date: formatSnapshotTime(vm.quote.priceAt), + })} + + ) : null} + + {vm.quote?.unavailable.length ? ( + + {t("stock.partialQuote")} + + ) : null} + + )} + {vm.quoteState.refreshing || vm.bondState.refreshing ? ( + + {t("accounts.refreshing")} + + ) : null} +
+ + {t("bond.terms")} + +
+ + + + + + +
+
+
+ + {vm.metrics ? ( + +
+ + + + + + +
+ {vm.metrics.creditRating ? ( + + + {t("bond.ratingSource", { + date: formatDate(vm.metrics.creditRating.date), + })} + + + {t("bond.ratingChecked", { + date: formatSnapshotTime( + vm.metrics.creditRating.observedAt, + ), + })} + + + ) : null} + + {t("bond.ratingCoverage")} + + {vm.metrics.effectiveYield ? ( + + {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), + })} + + ) : ( + + {t("bond.yieldUnavailable")} + + )} + + {t("bond.couponYieldNote")} + + {vm.metrics.couponYieldPriceAt ? ( + + {t("bond.couponYieldPriceAt", { + date: formatSnapshotTime(vm.metrics.couponYieldPriceAt), + })} + + ) : null} + {vm.metrics.unavailable.length ? ( + + {t("bond.metricsPartial")} + + ) : null} +
+ ) : null} +
+ + + + {vm.coupons ? ( + + {vm.coupons.estimateUnavailable || vm.coupons.positionIsStale ? ( + {t("stock.estimateUnavailable")} + ) : null} + {vm.upcoming.length ? ( + vm.upcoming.map((coupon) => ( + + )) + ) : ( + {t("bond.noCoupons")} + )} + + {t("bond.couponWindow", { + from: formatDate(vm.coupons.from), + to: formatDate(vm.coupons.to), + })} + + + {t("bond.couponNote")} + + {vm.coupons.positionAt ? ( + + {t("stock.snapshotAt", { + date: formatSnapshotTime(vm.coupons.positionAt), + })} + + ) : null} + + + {t("bond.couponHistory")} + + + {vm.pastCoupons.length ? ( + vm.pastCoupons.map((coupon) => ( + + )) + ) : ( + {t("bond.noCoupons")} + )} + + + + + + ) : null} + + +
+ ); +}; + +type MetricProps = { label: string; value: string }; +const Metric: FC = (props) => ( +
+ + {props.label} + + + {props.value} + +
+); +type CouponProps = { + coupon: NonNullable["coupons"][number]; +}; +const Coupon: FC = (props) => { + const { coupon } = props; + const { t } = useTranslation(); + return ( + + +
+ + {coupon.paymentDate + ? formatDate(coupon.paymentDate) + : t("stock.dateUnknown")} + + + {t("bond.couponNumber", { + number: coupon.number ?? t("bond.unknown"), + })} + +
+ + {coupon.estimatedAmount ? ( + + ) : null} +
+ + + {t(`bond.couponTypes.${coupon.couponType}`, { + defaultValue: t("bond.unknown"), + })} + + {coupon.periodDays != null ? ( + + {t("bond.couponDays", { count: coupon.periodDays })} + + ) : null} + {coupon.recordDate ? ( + + {t("stock.record")}: {formatDate(coupon.recordDate)} + + ) : null} + +
+ ); +}; + +export { BondPageView }; diff --git a/src/pages/bond/ui/BondPage.vm.ts b/src/pages/bond/ui/BondPage.vm.ts new file mode 100644 index 0000000..9819885 --- /dev/null +++ b/src/pages/bond/ui/BondPage.vm.ts @@ -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 = { + 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; + +export { type BondPageVm, useBondPageVm }; diff --git a/src/pages/bonds/index.ts b/src/pages/bonds/index.ts new file mode 100644 index 0000000..4328b67 --- /dev/null +++ b/src/pages/bonds/index.ts @@ -0,0 +1 @@ +export { BondsPage } from "./ui/BondsPage"; diff --git a/src/pages/bonds/ui/BondsPage.module.css b/src/pages/bonds/ui/BondsPage.module.css new file mode 100644 index 0000000..ab07ae0 --- /dev/null +++ b/src/pages/bonds/ui/BondsPage.module.css @@ -0,0 +1,36 @@ +.card { + display: grid; + grid-template-columns: auto minmax(0, 1fr) minmax(150px, 210px); + gap: var(--mantine-spacing-md); + align-items: start; + padding: var(--mantine-spacing-lg); + border-radius: var(--mantine-radius-lg); + color: inherit; + text-decoration: none; +} +.card:hover { + border-color: var(--mantine-color-blue-4); + background: var(--mantine-color-blue-0); +} +.card:focus-visible { + outline: 2px solid var(--mantine-color-blue-6); + outline-offset: 3px; +} +.identity { + min-width: 0; + overflow-wrap: anywhere; +} +.quote { + text-align: right; + font-variant-numeric: tabular-nums; +} +@media (max-width: 40em) { + .card { + grid-template-columns: auto minmax(0, 1fr); + padding: var(--mantine-spacing-md); + } + .quote { + grid-column: 2; + text-align: left; + } +} diff --git a/src/pages/bonds/ui/BondsPage.test.tsx b/src/pages/bonds/ui/BondsPage.test.tsx new file mode 100644 index 0000000..3242caf --- /dev/null +++ b/src/pages/bonds/ui/BondsPage.test.tsx @@ -0,0 +1,66 @@ +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 } 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 { BondsPage } from "./BondsPage"; + +describe("bond search", () => { + it("waits for a meaningful query and uses the server-verified ticker link", async () => { + const query = vi.fn(); + server.use( + http.get("*/api/v1/bonds", ({ request }) => { + query(new URL(request.url).searchParams.get("query")); + return HttpResponse.json([ + { + name: "ОФЗ", + ticker: "SU26238RMFS4", + instrumentUid: "unique-bond", + slug: "SU26238RMFS4", + logoName: "sber.png", + classCode: "TQOB", + isin: "RU0009029540", + realExchange: "REAL_EXCHANGE_MOEX", + shareType: "SHARE_TYPE_COMMON", + apiTradeAvailable: true, + pricePercent: "98.125", + maturityDate: "2030-05-15", + perpetual: false, + priceAt: "2026-09-10T10:00:00Z", + }, + ]); + }), + ); + configureAuthClient({ + accessToken: () => "synthetic", + refresh: async () => false, + clear: vi.fn(), + }); + render( + + + + + + + , + ); + expect(query).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("Поиск облигаций"), { + target: { value: "SU26238RMFS4" }, + }); + expect(await screen.findByRole("link", { name: /ОФЗ/ })).toHaveAttribute( + "href", + "/bonds/SU26238RMFS4", + ); + expect(screen.getByText("98,125 %")).toBeVisible(); + expect(screen.getByText("Режим: TQOB")).toBeVisible(); + expect(screen.getByText("ISIN: RU0009029540")).toBeVisible(); + expect(screen.getByText("Мосбиржа")).toBeVisible(); + await waitFor(() => expect(query).toHaveBeenCalledWith("SU26238RMFS4")); + }); +}); diff --git a/src/pages/bonds/ui/BondsPage.tsx b/src/pages/bonds/ui/BondsPage.tsx new file mode 100644 index 0000000..ded5852 --- /dev/null +++ b/src/pages/bonds/ui/BondsPage.tsx @@ -0,0 +1,7 @@ +import type { FC } from "react"; +import { BondsPageView } from "./BondsPage.view"; +import { useBondsPageVm } from "./BondsPage.vm"; + +const BondsPage: FC = () => ; + +export { BondsPage }; diff --git a/src/pages/bonds/ui/BondsPage.view.tsx b/src/pages/bonds/ui/BondsPage.view.tsx new file mode 100644 index 0000000..f22e6c9 --- /dev/null +++ b/src/pages/bonds/ui/BondsPage.view.tsx @@ -0,0 +1,176 @@ +import { + Alert, + Anchor, + Avatar, + Badge, + Button, + Group, + Paper, + Skeleton, + Stack, + Text, + TextInput, + Title, +} from "@mantine/core"; +import type { FC } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { + formatDate, + formatDecimal, + formatMoney, + formatSnapshotTime, +} from "@/shared/lib"; +import { LoadingSkeleton } from "@/shared/ui"; +import classes from "./BondsPage.module.css"; +import type { BondsPageVm } from "./BondsPage.vm"; + +type BondsPageViewProps = { vm: BondsPageVm }; +const BondsPageView: FC = (props) => { + const { vm } = props; + const { t } = useTranslation(); + return ( + +
+ {t("bond.searchTitle")} + + {t("bond.searchSubtitle")} + +
+ vm.setQuery(event.currentTarget.value)} + /> + {vm.query.trim().length < 2 ? ( + {t("stock.searchHint")} + ) : null} + {vm.error ? ( + + + + {t( + vm.code === "TBANK_NOT_CONNECTED" || + vm.code === "INVALID_TBANK_TOKEN" + ? "bond.connect" + : "stock.sectionError", + )} + + + {t("accounts.openSettings")} + + + + + ) : null} + {vm.loading ? ( + + + + + + + + ) : vm.ready ? ( + vm.items.length ? ( + + + {t("bond.searchVariants")} + + {vm.items.map((bond) => ( + + + {bond.name[0]} + +
+ + {bond.name} + + + {bond.ticker} ·{" "} + {bond.perpetual + ? t("bond.perpetual") + : t("bond.matures", { + date: bond.maturityDate + ? formatDate(bond.maturityDate) + : t("bond.unknown"), + })} + + + + {t(`stock.exchanges.${bond.realExchange}`, { + defaultValue: t("stock.exchangeUnknown"), + })} + + {bond.classCode ? ( + + {t("stock.classCode", { code: bond.classCode })} + + ) : null} + + {bond.isin ? ( + + {t("stock.isin", { value: bond.isin })} + + ) : null} + + {t("bond.nominal")}: {formatMoney(bond.nominal)} ·{" "} + {t("bond.couponFrequency")}:{" "} + {bond.couponQuantityPerYear ?? t("bond.unknown")} + + {bond.floatingCoupon ? ( + + {t("bond.floatingCoupon")} + + ) : null} + {bond.apiTradeAvailable === false ? ( + + {t("stock.apiUnavailable")} + + ) : null} +
+
+ + {t("bond.percentOfNominal")} + + + {bond.pricePercent !== null + ? `${formatDecimal(bond.pricePercent)} %` + : t("bond.unknown")} + + + {bond.pricePercent !== null && bond.priceAt + ? t("stock.priceAt", { + date: formatSnapshotTime(bond.priceAt), + }) + : t("stock.priceUnavailable")} + + {bond.apiTradeAvailable === true ? ( + + {t("stock.apiAvailable")} + + ) : null} +
+
+ ))} +
+ ) : ( + {t("bond.noSearchResults")} + ) + ) : null} +
+ ); +}; + +export { BondsPageView }; diff --git a/src/pages/bonds/ui/BondsPage.vm.ts b/src/pages/bonds/ui/BondsPage.vm.ts new file mode 100644 index 0000000..dc6fac7 --- /dev/null +++ b/src/pages/bonds/ui/BondsPage.vm.ts @@ -0,0 +1,33 @@ +import { useDebouncedValue } from "@mantine/hooks"; +import { useSearchParams } from "react-router"; +import { useBondSearch } from "@/entities/bond"; +import { apiErrorCode } from "@/shared/api"; +import { instrumentLogoUrl } from "@/shared/lib"; + +function useBondsPageVm() { + const [params, setParams] = useSearchParams(); + const query = params.get("q") ?? ""; + const [debounced] = useDebouncedValue(query.trim(), 300); + const result = useBondSearch(debounced); + const waiting = query.trim() !== debounced; + return { + query, + setQuery: (q: string) => setParams(q ? { q } : {}, { replace: true }), + loading: query.trim().length >= 2 && (waiting || result.isLoading), + error: result.isError, + code: apiErrorCode(result.error), + retry: () => { + void result.refetch(); + }, + ready: debounced.length >= 2 && !waiting && Boolean(result.data), + items: + result.data?.map((bond) => ({ + ...bond, + to: `/bonds/${encodeURIComponent(bond.slug ?? bond.instrumentUid)}`, + logoSrc: instrumentLogoUrl(bond.logoName), + })) ?? [], + }; +} +type BondsPageVm = ReturnType; + +export { type BondsPageVm, useBondsPageVm }; diff --git a/src/pages/stock/ui/StockPage.module.css b/src/pages/stock/ui/StockPage.module.css index 1d88120..0fffd90 100644 --- a/src/pages/stock/ui/StockPage.module.css +++ b/src/pages/stock/ui/StockPage.module.css @@ -28,30 +28,6 @@ .negative { color: var(--mantine-color-red-8); } -.metrics { - display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: var(--mantine-spacing-lg); -} -.metricValue { - font-variant-numeric: tabular-nums; - font-size: var(--mantine-font-size-xl); - font-weight: 600; - overflow-wrap: anywhere; -} -.holding { - background: var(--mantine-color-blue-0); - padding: var(--mantine-spacing-lg); - border-radius: var(--mantine-radius-md); -} -.accountRow { - display: grid; - grid-template-columns: 1.2fr repeat(4, minmax(0, 1fr)); - gap: var(--mantine-spacing-sm); - padding: var(--mantine-spacing-md) 0; - border-top: 1px solid var(--wl-color-border); - align-items: start; -} .dividend { padding: var(--mantine-spacing-md); background: var(--mantine-color-green-0); @@ -62,30 +38,6 @@ grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); gap: var(--mantine-spacing-lg); } -.table { - overflow-x: auto; - max-width: 100%; -} -.controls { - max-width: 100%; - overflow-x: auto; -} -@media (max-width: 48em) { - .section { - padding: var(--mantine-spacing-md); - } - .metrics, - .companyMetrics { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .accountRow { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .accountRow > :first-child { - grid-column: 1 / -1; - } -} - .embeddedSection { margin-top: var(--mantine-spacing-lg); padding-top: var(--mantine-spacing-lg); @@ -96,14 +48,11 @@ background: var(--wl-color-subtle); border-radius: var(--mantine-radius-md); } -.priceTable { - margin-top: var(--mantine-spacing-md); -} -.priceTable table { - font-variant-numeric: tabular-nums; - white-space: nowrap; -} -.priceTable th:nth-child(2), -.priceTable td:nth-child(2) { - text-align: right; +@media (max-width: 48em) { + .section { + padding: var(--mantine-spacing-md); + } + .companyMetrics { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } diff --git a/src/pages/stock/ui/StockPage.view.tsx b/src/pages/stock/ui/StockPage.view.tsx index 1af221e..371acde 100644 --- a/src/pages/stock/ui/StockPage.view.tsx +++ b/src/pages/stock/ui/StockPage.view.tsx @@ -12,6 +12,12 @@ import { } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; +import { + InstrumentChart as StockChart, + InstrumentEvents as StockEvents, + InstrumentPosition as StockPosition, + InstrumentSection as StockSection, +} from "@/features/instrument-details"; import { formatDate, formatDecimal, @@ -19,10 +25,7 @@ import { formatSnapshotTime, } from "@/shared/lib"; import { LoadingSkeleton } from "@/shared/ui"; -import { StockChart } from "./components/StockChart.view"; -import { StockDividends, StockEvents } from "./components/StockEvents.view"; -import { StockPosition } from "./components/StockPosition.view"; -import { StockSection } from "./components/StockSection.view"; +import { StockDividends } from "./components/StockEvents.view"; import classes from "./StockPage.module.css"; import type { StockPageVm } from "./StockPage.vm"; diff --git a/src/pages/stock/ui/StockPage.vm.ts b/src/pages/stock/ui/StockPage.vm.ts index d64d47f..2709f1b 100644 --- a/src/pages/stock/ui/StockPage.vm.ts +++ b/src/pages/stock/ui/StockPage.vm.ts @@ -15,32 +15,10 @@ import { useStockPosition, useStockQuote, } from "@/entities/stock"; +import { section } from "@/features/instrument-details"; import { apiErrorCode } from "@/shared/api"; import { formatDate, instrumentLogoUrl } from "@/shared/lib"; -type SectionState = { - hasData: boolean; - loading: boolean; - error: boolean; - refreshing: boolean; - retry: () => void; -}; -function section(query: { - data: unknown; - isError: boolean; - isFetching: boolean; - refetch: () => unknown; -}): SectionState { - return { - hasData: Boolean(query.data), - loading: !query.data && !query.isError, - error: query.isError, - refreshing: Boolean(query.data) && query.isFetching, - retry: () => { - void query.refetch(); - }, - }; -} const periods: StockPeriod[] = ["month", "halfYear", "year", "all"]; const filters = ["all", "trades", "dividends", "other"] as const; type StockEventFilter = (typeof filters)[number]; @@ -172,4 +150,4 @@ function useStockPageVm() { } type StockPageVm = ReturnType; -export { type SectionState, type StockPageVm, useStockPageVm }; +export { type StockPageVm, useStockPageVm }; diff --git a/src/pages/stock/ui/components/StockEvents.view.tsx b/src/pages/stock/ui/components/StockEvents.view.tsx index 984618d..5d8d6be 100644 --- a/src/pages/stock/ui/components/StockEvents.view.tsx +++ b/src/pages/stock/ui/components/StockEvents.view.tsx @@ -2,20 +2,17 @@ import { Accordion, Alert, Badge, - Button, Group, Paper, - SegmentedControl, Stack, Text, } from "@mantine/core"; import type { FC } from "react"; import { useTranslation } from "react-i18next"; -import { LedgerEntryRow } from "@/entities/broker-operation"; +import { InstrumentSection as StockSection } from "@/features/instrument-details"; import { formatDate, formatMoney, formatSnapshotTime } from "@/shared/lib"; import classes from "../StockPage.module.css"; import type { StockPageVm } from "../StockPage.vm"; -import { StockSection } from "./StockSection.view"; type StockEventsProps = { vm: StockPageVm }; const StockDividends: FC = (props) => { @@ -167,67 +164,5 @@ const StockDividends: FC = (props) => { ); }; -const StockEvents: FC = (props) => { - const { vm } = props; - const { t } = useTranslation(); - return ( - - ({ - value, - label: t(`stock.filters.${value}`), - }))} - /> -
- } - > - {vm.operations ? ( - <> - - {t("stock.historyNote")} - - {vm.eventRows.length ? ( - - {vm.eventRows.map((event) => ( - - ))} - - ) : ( - - {t("stock.noOperations")} - - )} - - - - - - ) : null} -
- ); -}; -export { StockDividends, StockEvents }; +export { StockDividends }; diff --git a/src/shared/api/generated/schema.d.ts b/src/shared/api/generated/schema.d.ts index 8eee697..1bc836a 100644 --- a/src/shared/api/generated/schema.d.ts +++ b/src/shared/api/generated/schema.d.ts @@ -212,6 +212,134 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/bonds/{uid}/metrics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BondController_metrics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bonds": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BondController_search"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bonds/by-ticker/{ticker}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BondController_getByTicker"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bonds/{uid}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BondController_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bonds/{uid}/quote": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BondController_quote"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bonds/{uid}/candles": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BondController_candles"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bonds/{uid}/position": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BondController_position"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/bonds/{uid}/coupons": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["BondController_coupons"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/tbank-connection": { parameters: { query?: never; @@ -582,6 +710,165 @@ export interface components { estimateUnavailable: boolean; positionIsStale: boolean; }; + BondCreditRatingDto: { + /** @enum {string} */ + agency: "NCR"; + value: string | null; + /** @enum {string} */ + status: "active" | "withdrawn"; + /** Format: date */ + date: string; + /** Format: uri */ + sourceUrl: string; + /** Format: date-time */ + observedAt: string; + }; + BondYieldDto: { + /** @description MOEX effective annual yield in percent. */ + value: string; + pricePercent: string; + /** Format: date */ + date: string; + /** @description MOEX yield target, e.g. MATDATE or OFFERDATE. */ + dateType: string | null; + /** Format: date-time */ + tradeAt: string; + }; + BondMetricsDto: { + creditRating: components["schemas"]["BondCreditRatingDto"] | null; + coupon: components["schemas"]["MoneyDto"] | null; + /** Format: date */ + couponDate: string | null; + couponType: string | null; + couponPeriodDays: number | null; + /** @description Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity. */ + couponYieldPercent: string | null; + /** Format: date-time */ + couponYieldPriceAt: string | null; + effectiveYield: components["schemas"]["BondYieldDto"] | null; + unavailable: string[]; + /** Format: date-time */ + observedAt: string; + }; + BondSearchResultDto: { + /** Format: uuid */ + instrumentUid: string; + /** @description Unambiguous ticker URL segment, or the exact instrument UID. */ + slug: string; + name: string; + isin: string | null; + classCode: string | null; + realExchange: string | null; + apiTradeAvailable: boolean | null; + ticker: string | null; + logoName: string | null; + currency: string | null; + sector: string | null; + lot: number | null; + /** Format: date-time */ + historyFrom: string | null; + nominal: components["schemas"]["MoneyDto"] | null; + initialNominal: components["schemas"]["MoneyDto"] | null; + aci: components["schemas"]["MoneyDto"] | null; + maturityDate: string | null; + callDate: string | null; + bondType: string | null; + riskLevel: string | null; + couponQuantityPerYear: number | null; + floatingCoupon: boolean | null; + perpetual: boolean | null; + amortization: boolean | null; + subordinated: boolean | null; + /** @description Clean price in percent of nominal, not money. */ + pricePercent: string | null; + /** Format: date-time */ + priceAt: string | null; + }; + BondDto: { + /** Format: uuid */ + instrumentUid: string; + /** @description Unambiguous ticker URL segment, or the exact instrument UID. */ + slug: string; + name: string; + isin: string | null; + classCode: string | null; + realExchange: string | null; + apiTradeAvailable: boolean | null; + ticker: string | null; + logoName: string | null; + currency: string | null; + sector: string | null; + lot: number | null; + /** Format: date-time */ + historyFrom: string | null; + nominal: components["schemas"]["MoneyDto"] | null; + initialNominal: components["schemas"]["MoneyDto"] | null; + aci: components["schemas"]["MoneyDto"] | null; + maturityDate: string | null; + callDate: string | null; + bondType: string | null; + riskLevel: string | null; + couponQuantityPerYear: number | null; + floatingCoupon: boolean | null; + perpetual: boolean | null; + amortization: boolean | null; + subordinated: boolean | null; + }; + BondQuoteDto: { + pricePercent: string | null; + /** @description Indicative clean value at the current nominal; no FX conversion. */ + cleanPrice: components["schemas"]["MoneyDto"] | null; + aci: components["schemas"]["MoneyDto"] | null; + /** @description Indicative clean value plus current accrued interest, excluding fees. */ + dirtyPrice: components["schemas"]["MoneyDto"] | null; + changePercentagePoints: string | null; + previousCloseDate: string | null; + tradingStatus: string | null; + /** Format: date-time */ + priceAt: string | null; + unavailable: string[]; + /** Format: date-time */ + observedAt: string; + }; + BondCandlesDto: { + candles: components["schemas"]["StockCandleDto"][]; + currency: string | null; + /** Format: date-time */ + historyFrom: string | null; + /** Format: date-time */ + observedAt: string; + }; + BondPositionDto: { + quantity: string; + currentValue: components["schemas"]["MoneyDto"] | null; + averagePurchasePrice: components["schemas"]["MoneyDto"] | null; + absoluteReturn: components["schemas"]["MoneyDto"] | null; + returnPercent: string | null; + accounts: components["schemas"]["StockPositionRowDto"][]; + isStale: boolean; + /** Format: date-time */ + observedAt: string; + }; + BondCouponDto: { + number: string | null; + paymentDate: string | null; + recordDate: string | null; + couponType: string | null; + periodDays: number | null; + perBond: components["schemas"]["MoneyDto"] | null; + estimatedAmount: components["schemas"]["MoneyDto"] | null; + /** @enum {string} */ + timing: "upcoming" | "past" | "undated"; + }; + BondCouponsDto: { + coupons: components["schemas"]["BondCouponDto"][]; + from: string; + to: string; + observedAt: string; + positionAt: string | null; + estimateUnavailable: boolean; + positionIsStale: boolean; + }; TbankConnectionStatusDto: { connected: boolean; /** Format: date-time */ @@ -1651,6 +1938,536 @@ export interface operations { }; }; }; + BondController_metrics: { + parameters: { + query?: never; + header?: never; + path: { + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BondMetricsDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BondController_search: { + parameters: { + query: { + query: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BondSearchResultDto"][]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BondController_getByTicker: { + parameters: { + query?: never; + header?: never; + path: { + ticker: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BondDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BondController_get: { + parameters: { + query?: never; + header?: never; + path: { + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BondDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BondController_quote: { + parameters: { + query?: never; + header?: never; + path: { + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BondQuoteDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BondController_candles: { + parameters: { + query?: { + period?: "month" | "halfYear" | "year" | "all"; + }; + header?: never; + path: { + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BondCandlesDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BondController_position: { + parameters: { + query?: never; + header?: never; + path: { + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BondPositionDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; + BondController_coupons: { + parameters: { + query?: never; + header?: never; + path: { + uid: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["BondCouponsDto"]; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Authentication required */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Resource not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Internal server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + /** @description Upstream service unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["ProblemDetailsDto"]; + }; + }; + }; + }; TbankConnectionController_status: { parameters: { query?: never; diff --git a/src/shared/api/spec/openapi.json b/src/shared/api/spec/openapi.json index e559d6f..f322455 100644 --- a/src/shared/api/spec/openapi.json +++ b/src/shared/api/spec/openapi.json @@ -1077,6 +1077,709 @@ ] } }, + "/api/v1/bonds/{uid}/metrics": { + "get": { + "operationId": "BondController_metrics", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BondMetricsDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bonds" + ] + } + }, + "/api/v1/bonds": { + "get": { + "operationId": "BondController_search", + "parameters": [ + { + "name": "query", + "required": true, + "in": "query", + "schema": { + "minLength": 2, + "maxLength": 80, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BondSearchResultDto" + } + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bonds" + ] + } + }, + "/api/v1/bonds/by-ticker/{ticker}": { + "get": { + "operationId": "BondController_getByTicker", + "parameters": [ + { + "name": "ticker", + "required": true, + "in": "path", + "schema": { + "minLength": 1, + "maxLength": 80, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._@+-]*$", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BondDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bonds" + ] + } + }, + "/api/v1/bonds/{uid}": { + "get": { + "operationId": "BondController_get", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BondDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bonds" + ] + } + }, + "/api/v1/bonds/{uid}/quote": { + "get": { + "operationId": "BondController_quote", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BondQuoteDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bonds" + ] + } + }, + "/api/v1/bonds/{uid}/candles": { + "get": { + "operationId": "BondController_candles", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + }, + { + "name": "period", + "required": false, + "in": "query", + "schema": { + "default": "month", + "type": "string", + "enum": [ + "month", + "halfYear", + "year", + "all" + ] + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BondCandlesDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bonds" + ] + } + }, + "/api/v1/bonds/{uid}/position": { + "get": { + "operationId": "BondController_position", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BondPositionDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bonds" + ] + } + }, + "/api/v1/bonds/{uid}/coupons": { + "get": { + "operationId": "BondController_coupons", + "parameters": [ + { + "name": "uid", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BondCouponsDto" + } + } + } + }, + "400": { + "description": "Invalid request", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + }, + "503": { + "description": "Upstream service unavailable", + "content": { + "application/problem+json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetailsDto" + } + } + } + } + }, + "security": [ + { + "bearer": [] + } + ], + "tags": [ + "bonds" + ] + } + }, "/api/v1/tbank-connection": { "get": { "operationId": "TbankConnectionController_status", @@ -3088,6 +3791,732 @@ "positionIsStale" ] }, + "BondCreditRatingDto": { + "type": "object", + "properties": { + "agency": { + "type": "string", + "enum": [ + "NCR" + ] + }, + "value": { + "type": "string", + "nullable": true + }, + "status": { + "type": "string", + "enum": [ + "active", + "withdrawn" + ] + }, + "date": { + "type": "string", + "format": "date" + }, + "sourceUrl": { + "type": "string", + "format": "uri" + }, + "observedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "agency", + "value", + "status", + "date", + "sourceUrl", + "observedAt" + ] + }, + "BondYieldDto": { + "type": "object", + "properties": { + "value": { + "type": "string", + "description": "MOEX effective annual yield in percent." + }, + "pricePercent": { + "type": "string" + }, + "date": { + "type": "string", + "format": "date" + }, + "dateType": { + "type": "string", + "nullable": true, + "description": "MOEX yield target, e.g. MATDATE or OFFERDATE." + }, + "tradeAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "value", + "pricePercent", + "date", + "dateType", + "tradeAt" + ] + }, + "BondMetricsDto": { + "type": "object", + "properties": { + "creditRating": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/BondCreditRatingDto" + } + ] + }, + "coupon": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "couponDate": { + "type": "string", + "nullable": true, + "format": "date" + }, + "couponType": { + "type": "string", + "nullable": true + }, + "couponPeriodDays": { + "type": "number", + "nullable": true + }, + "couponYieldPercent": { + "type": "string", + "nullable": true, + "description": "Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity." + }, + "couponYieldPriceAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "effectiveYield": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/BondYieldDto" + } + ] + }, + "unavailable": { + "type": "array", + "items": { + "type": "string" + } + }, + "observedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "creditRating", + "coupon", + "couponDate", + "couponType", + "couponPeriodDays", + "couponYieldPercent", + "couponYieldPriceAt", + "effectiveYield", + "unavailable", + "observedAt" + ] + }, + "BondSearchResultDto": { + "type": "object", + "properties": { + "instrumentUid": { + "type": "string", + "format": "uuid" + }, + "slug": { + "type": "string", + "description": "Unambiguous ticker URL segment, or the exact instrument UID." + }, + "name": { + "type": "string" + }, + "isin": { + "type": "string", + "nullable": true + }, + "classCode": { + "type": "string", + "nullable": true + }, + "realExchange": { + "type": "string", + "nullable": true + }, + "apiTradeAvailable": { + "type": "boolean", + "nullable": true + }, + "ticker": { + "type": "string", + "nullable": true + }, + "logoName": { + "type": "string", + "nullable": true + }, + "currency": { + "type": "string", + "nullable": true + }, + "sector": { + "type": "string", + "nullable": true + }, + "lot": { + "type": "number", + "nullable": true + }, + "historyFrom": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "nominal": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "initialNominal": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "aci": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "maturityDate": { + "type": "string", + "nullable": true + }, + "callDate": { + "type": "string", + "nullable": true + }, + "bondType": { + "type": "string", + "nullable": true + }, + "riskLevel": { + "type": "string", + "nullable": true + }, + "couponQuantityPerYear": { + "type": "number", + "nullable": true + }, + "floatingCoupon": { + "type": "boolean", + "nullable": true + }, + "perpetual": { + "type": "boolean", + "nullable": true + }, + "amortization": { + "type": "boolean", + "nullable": true + }, + "subordinated": { + "type": "boolean", + "nullable": true + }, + "pricePercent": { + "type": "string", + "nullable": true, + "description": "Clean price in percent of nominal, not money." + }, + "priceAt": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "instrumentUid", + "slug", + "name", + "isin", + "classCode", + "realExchange", + "apiTradeAvailable", + "ticker", + "logoName", + "currency", + "sector", + "lot", + "historyFrom", + "nominal", + "initialNominal", + "aci", + "maturityDate", + "callDate", + "bondType", + "riskLevel", + "couponQuantityPerYear", + "floatingCoupon", + "perpetual", + "amortization", + "subordinated", + "pricePercent", + "priceAt" + ] + }, + "BondDto": { + "type": "object", + "properties": { + "instrumentUid": { + "type": "string", + "format": "uuid" + }, + "slug": { + "type": "string", + "description": "Unambiguous ticker URL segment, or the exact instrument UID." + }, + "name": { + "type": "string" + }, + "isin": { + "type": "string", + "nullable": true + }, + "classCode": { + "type": "string", + "nullable": true + }, + "realExchange": { + "type": "string", + "nullable": true + }, + "apiTradeAvailable": { + "type": "boolean", + "nullable": true + }, + "ticker": { + "type": "string", + "nullable": true + }, + "logoName": { + "type": "string", + "nullable": true + }, + "currency": { + "type": "string", + "nullable": true + }, + "sector": { + "type": "string", + "nullable": true + }, + "lot": { + "type": "number", + "nullable": true + }, + "historyFrom": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "nominal": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "initialNominal": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "aci": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "maturityDate": { + "type": "string", + "nullable": true + }, + "callDate": { + "type": "string", + "nullable": true + }, + "bondType": { + "type": "string", + "nullable": true + }, + "riskLevel": { + "type": "string", + "nullable": true + }, + "couponQuantityPerYear": { + "type": "number", + "nullable": true + }, + "floatingCoupon": { + "type": "boolean", + "nullable": true + }, + "perpetual": { + "type": "boolean", + "nullable": true + }, + "amortization": { + "type": "boolean", + "nullable": true + }, + "subordinated": { + "type": "boolean", + "nullable": true + } + }, + "required": [ + "instrumentUid", + "slug", + "name", + "isin", + "classCode", + "realExchange", + "apiTradeAvailable", + "ticker", + "logoName", + "currency", + "sector", + "lot", + "historyFrom", + "nominal", + "initialNominal", + "aci", + "maturityDate", + "callDate", + "bondType", + "riskLevel", + "couponQuantityPerYear", + "floatingCoupon", + "perpetual", + "amortization", + "subordinated" + ] + }, + "BondQuoteDto": { + "type": "object", + "properties": { + "pricePercent": { + "type": "string", + "nullable": true + }, + "cleanPrice": { + "nullable": true, + "description": "Indicative clean value at the current nominal; no FX conversion.", + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "aci": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "dirtyPrice": { + "nullable": true, + "description": "Indicative clean value plus current accrued interest, excluding fees.", + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "changePercentagePoints": { + "type": "string", + "nullable": true + }, + "previousCloseDate": { + "type": "string", + "nullable": true + }, + "tradingStatus": { + "type": "string", + "nullable": true + }, + "priceAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "unavailable": { + "type": "array", + "items": { + "type": "string" + } + }, + "observedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "pricePercent", + "cleanPrice", + "aci", + "dirtyPrice", + "changePercentagePoints", + "previousCloseDate", + "tradingStatus", + "priceAt", + "unavailable", + "observedAt" + ] + }, + "BondCandlesDto": { + "type": "object", + "properties": { + "candles": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockCandleDto" + } + }, + "currency": { + "type": "string", + "nullable": true + }, + "historyFrom": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "observedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "candles", + "currency", + "historyFrom", + "observedAt" + ] + }, + "BondPositionDto": { + "type": "object", + "properties": { + "quantity": { + "type": "string" + }, + "currentValue": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "averagePurchasePrice": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "absoluteReturn": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "returnPercent": { + "type": "string", + "nullable": true + }, + "accounts": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StockPositionRowDto" + } + }, + "isStale": { + "type": "boolean" + }, + "observedAt": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "quantity", + "currentValue", + "averagePurchasePrice", + "absoluteReturn", + "returnPercent", + "accounts", + "isStale", + "observedAt" + ] + }, + "BondCouponDto": { + "type": "object", + "properties": { + "number": { + "type": "string", + "nullable": true + }, + "paymentDate": { + "type": "string", + "nullable": true + }, + "recordDate": { + "type": "string", + "nullable": true + }, + "couponType": { + "type": "string", + "nullable": true + }, + "periodDays": { + "type": "number", + "nullable": true + }, + "perBond": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "estimatedAmount": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/MoneyDto" + } + ] + }, + "timing": { + "type": "string", + "enum": [ + "upcoming", + "past", + "undated" + ] + } + }, + "required": [ + "number", + "paymentDate", + "recordDate", + "couponType", + "periodDays", + "perBond", + "estimatedAmount", + "timing" + ] + }, + "BondCouponsDto": { + "type": "object", + "properties": { + "coupons": { + "type": "array", + "items": { + "$ref": "#/components/schemas/BondCouponDto" + } + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + }, + "observedAt": { + "type": "string" + }, + "positionAt": { + "type": "string", + "nullable": true + }, + "estimateUnavailable": { + "type": "boolean" + }, + "positionIsStale": { + "type": "boolean" + } + }, + "required": [ + "coupons", + "from", + "to", + "observedAt", + "positionAt", + "estimateUnavailable", + "positionIsStale" + ] + }, "TbankConnectionStatusDto": { "type": "object", "properties": { diff --git a/src/shared/i18n/locales/ru.ts b/src/shared/i18n/locales/ru.ts index b7284a5..8b7bad3 100644 --- a/src/shared/i18n/locales/ru.ts +++ b/src/shared/i18n/locales/ru.ts @@ -1,7 +1,12 @@ const ru = { translation: { appName: "WealthLedger", - navigation: { accounts: "Счета", stocks: "Акции", settings: "Настройки" }, + navigation: { + accounts: "Счета", + stocks: "Акции", + bonds: "Облигации", + settings: "Настройки", + }, breadcrumbs: { label: "Хлебные крошки", accounts: "Брокерские счета", @@ -11,6 +16,104 @@ const ru = { events: "Будущие события", settings: "Настройки", }, + bond: { + instrument: "Облигация", + searching: "Ищем облигации", + noSearchResults: + "Облигации не найдены. Попробуйте другое название, тикер или ISIN.", + searchVariants: + "Одна карточка на выпуск (ISIN). Разные выпуски одного эмитента показаны отдельно.", + chartLabel: "Дневные цены облигации, {{currency}}", + quantity: "Количество облигаций", + historyNote: + "Операции по этой облигации на доступных открытых счетах, включая прошлые годы. Дата сделки — дата операции брокера; количество — исполненная часть. История закрытых счетов не включена.", + couponWindow: + "Календарь выпуска с {{from}} по {{to}}. Личные зачисления показаны в истории событий.", + searchTitle: "Облигации", + searchSubtitle: "Найдите выпуск по названию, тикеру или ISIN.", + searchLabel: "Поиск облигаций", + searchPlaceholder: "Например, ОФЗ или SU26238RMFS4", + loading: "Загрузка облигации", + unavailable: "Облигация недоступна", + unavailableText: "Не удалось найти выпуск или загрузить его данные.", + connect: "Подключите Т-Банк в настройках, чтобы открыть облигации.", + ambiguousTicker: + "Тикер соответствует нескольким выпускам. Выберите облигацию через поиск.", + backToSearch: "К поиску облигаций", + lot: "Размер лота", + unknown: "—", + percentOfNominal: "% от номинала", + chartNote: + "Дневная цена закрытия в процентах от номинала, без НКД. Исторические цены не пересчитаны в деньги по сегодняшнему номиналу.", + terms: "Условия выпуска", + nominal: "Текущий номинал", + initialNominal: "Первоначальный номинал", + maturity: "Погашение", + matures: "Погашение {{date}}", + callDate: "Оферта", + couponFrequency: "Купонов в год", + floatingCoupon: "Плавающий купон", + perpetual: "Бессрочная", + amortization: "С амортизацией", + subordinated: "Субординированная", + aci: "НКД на одну облигацию", + cleanPrice: "Без НКД", + dirtyPrice: "С НКД, без комиссии", + indicativePrice: + "Денежная оценка рассчитана по текущему номиналу и НКД. Котировка может относиться к более ранним торгам.", + percentagePoints: "п. п.", + coupons: "Купоны", + upcomingCoupons: "Ближайшие купоны", + couponHistory: "Прошедшие и недатированные купоны", + couponNumber: "Купон № {{number}}", + perBond: "На одну облигацию", + couponDays: "Купонный период: {{count}} дн.", + couponNote: + "Календарь выпуска, не личные зачисления. Оценка по текущей позиции не подтверждает право на выплату и не учитывает налоги и будущие сделки. Неизвестная сумма не означает нулевой купон.", + returnNote: + "Изменение стоимости открытой позиции по ценам портфеля. Не включает выплаченные купоны и результат продаж; это не доходность к погашению.", + noCoupons: "В выбранном периоде известных купонов нет.", + metrics: "Купон, рейтинг и доходность", + creditRating: "Кредитный рейтинг НКР", + ratingWithdrawn: "Отозван", + ratingUnavailable: "Источник недоступен", + ratingMissing: "В реестре НКР не найден", + ratingSource: "НКР · рейтинг выпуска от {{date}}", + ratingChecked: "Реестр получен {{date}}", + ratingCoverage: + "Источник — реестр эмиссий НКР. Рейтинг сопоставлен по ISIN; отсутствие записи не исключает рейтингов других агентств.", + couponAmount: "Размер ближайшего купона", + couponType: "Тип купона", + couponDate: "Дата выплаты купона", + effectiveYield: "Эффективная доходность, годовых", + couponYield: "Текущая купонная доходность, годовых", + yieldUnavailable: + "MOEX не предоставила доходность для этого выпуска и режима торгов.", + yieldSource: + "MOEX · {{target}} {{date}} · цена расчёта {{price}} % · торги {{at}}. Данные могут поступать с задержкой.", + yieldTargets: { + MATDATE: "к погашению", + OFFERDATE: "к оферте", + PUTOPTIONDATE: "к оферте", + CALLOPTIONDATE: "к досрочному погашению", + unknown: "к дате", + }, + couponYieldNote: + "Купонная доходность = ближайший купон × 365 / дней в купонном периоде / цена без НКД × 100 %. Это годовая оценка при сохранении размера купона и номинала, без налогов, комиссий и реинвестирования; для плавающего или переменного купона будущий доход изменится.", + couponYieldPriceAt: + "Для купонной доходности использована котировка Т-Банка на {{date}}.", + metricsPartial: + "Часть показателей временно недоступна. Обновите раздел, чтобы повторить запрос.", + couponTypes: { + COUPON_TYPE_CONSTANT: "Постоянный", + COUPON_TYPE_FLOATING: "Плавающий", + COUPON_TYPE_DISCOUNT: "Дисконтный", + COUPON_TYPE_MORTGAGE: "Ипотечный", + COUPON_TYPE_FIX: "Фиксированный", + COUPON_TYPE_VARIABLE: "Переменный", + COUPON_TYPE_OTHER: "Другой", + }, + }, stock: { searchTitle: "Акции", searchSubtitle: diff --git a/src/shared/ui/AppShell.tsx b/src/shared/ui/AppShell.tsx index 072b251..342cc3e 100644 --- a/src/shared/ui/AppShell.tsx +++ b/src/shared/ui/AppShell.tsx @@ -15,6 +15,7 @@ const AppShell: FC = (props) => { const items = [ { to: "/accounts", label: t("navigation.accounts") }, { to: "/stocks", label: t("navigation.stocks") }, + { to: "/bonds", label: t("navigation.bonds") }, { to: "/settings", label: t("navigation.settings") }, ];