refactor(frontend): refactoring fsd
Some checks failed
CI / ci (pull_request) Failing after 3m2s
CI / ci (push) Failing after 2m58s

This commit is contained in:
Sergey Krylov 2026-06-20 19:58:49 +03:00
parent 40d7792b9e
commit 6f9e368126
43 changed files with 1125 additions and 529 deletions

View File

@ -0,0 +1,72 @@
# ADR-014: Frontend FSD market pages
**Статус:** Accepted
**Дата:** 2026-06-20
**Участники решения:** Product Engineer, Tech Lead
## Context
После `ADR-013` broker-домен уже получил первый рабочий FSD-срез, но market pages (`HomePage`,
`StockPage`, `BondPage`) и связанные UI-блоки всё ещё оставались в исторической технической
структуре `components/`, `hooks/` и плоского `pages/`. Из-за этого frontend оказался в
промежуточном состоянии: часть экранов уже живёт через FSD public API, а публичная документация
всё ещё описывает market-раздел как legacy-only область.
Для следующей итерации нужна управляемая миграция, которая:
- продолжает FSD-переход после broker pilot, не превращая его в big-bang переписывание frontend;
- переносит market pages и связанные UI/query entrypoints в `pages/`, `widgets/` и `entities/`;
- сохраняет совместимость со старыми импортами на время coexistence через тонкие re-export shim;
- оставляет orchestration route params, query composition и loading/not-found состояния в
page-layer, не размывая ответственность widgets.
## Options
### Option A. Полная market FSD migration с coexistence через shims
Перенести `HomePage`, `StockPage`, `BondPage`, `SearchBar`, `PriceChart`, `StockDetails`,
`BondDetails`, `DividendsTable` и `useSearch` в FSD-срезы. Сохранить старые entrypoints
(`components/*`, `hooks/useSearch.ts`, `pages/*.tsx`) как тонкие реэкспорт-шмы на переходный
период. Data orchestration и route-specific логика остаются в `pages/home`, `pages/stock`,
`pages/bond`.
### Option B. Частичный перенос только UI-компонентов
Перенести только `SearchBar`, `PriceChart`, `StockDetails`, `BondDetails` и `DividendsTable` в
`widgets/`, но оставить page entrypoints и `useSearch` в исторических каталогах.
### Option C. Ускоренная миграция без shim-слоя
Сразу заменить все старые import paths на новые FSD entrypoints и удалить legacy-файлы в рамках
одного изменения.
## Decision
Принят Option A: выполнить market-pages миграцию как следующую итерацию после broker pilot и вести
её через coexistence FSD-слоёв и legacy entrypoints.
Решение включает следующие правила:
- `pages/home`, `pages/stock`, `pages/bond` становятся primary route entrypoints;
- `widgets/search-bar`, `widgets/price-chart`, `widgets/stock-details`, `widgets/bond-details`,
`widgets/dividends-table` становятся primary UI entrypoints для market-сценариев;
- `entities/search/model/useSearch.ts` становится primary местом для search query hook;
- legacy `components/*`, `hooks/useSearch.ts` и плоские `pages/*.tsx` сохраняются как тонкие
re-export shim-файлы на переходный период;
- orchestration остаётся в page-layer: `useParams`, вычисление диапазонов дат, вызовы domain query
hooks, а также loading/error/not-found состояния не переносятся в widgets;
- взаимодействие между слоями идёт через public API (`index.ts`), без внешних deep imports в `ui/`
и `model/`.
## Consequences
- Market-раздел получает ту же архитектурную форму, что и broker pilot, и становится вторым
эталонным FSD-срезом frontend.
- Migration risk снижается: coexistence через shims позволяет переключать импортирующие места без
массового удаления legacy entrypoints.
- В проекте временно сохраняется двойная поверхность импортов, поэтому архитектурная дисциплина
по-прежнему поддерживается структурой каталогов и code review.
- Orchestration остаётся ближе к маршрутам, поэтому widgets остаются prop-driven и переиспользуемыми
без знания о `react-router` или query lifecycle.
- Published frontend docs и ADR index должны обновляться синхронно с такой миграцией, иначе
документация начнёт отставать от фактической структуры кода.

View File

@ -15,5 +15,6 @@
| [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC | | [ADR-011](ADR-011-tbank-invest-grpc) | Accepted | Интеграция с T-Bank Invest через gRPC |
| [ADR-012](ADR-012-frontend-broker-account-aggregation) | Accepted | Агрегация сводки брокерских счетов на frontend | | [ADR-012](ADR-012-frontend-broker-account-aggregation) | Accepted | Агрегация сводки брокерских счетов на frontend |
| [ADR-013](ADR-013-frontend-fsd-broker-pilot) | Accepted | Пилотная FSD-миграция broker-домена | | [ADR-013](ADR-013-frontend-fsd-broker-pilot) | Accepted | Пилотная FSD-миграция broker-домена |
| [ADR-014](ADR-014-frontend-fsd-market-pages) | Accepted | FSD-миграция market pages и market widgets |
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе. Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.

View File

@ -1,13 +1,16 @@
# Компоненты # Компоненты
## Layout (`components/Layout.tsx`) Published docs ниже перечисляют primary entrypoints. Legacy файлы в `components/` сохранены как
тонкие re-export shim'ы для coexistence со старыми импортами.
## Layout (`app/layouts/AppLayout.tsx`, legacy shim: `components/Layout.tsx`)
Базовый layout с шапкой и `<Outlet />`. Базовый layout с шапкой и `<Outlet />`.
- Шапка: логотип "MoexVibe" (ссылка на `/`) + `SearchBar` - Шапка: логотип "MoexVibe" (ссылка на `/`) + `SearchBar`
- Основной контент: max-width 1200px, padding 24px - Основной контент: max-width 1200px, padding 24px
## SearchBar (`components/SearchBar.tsx`) ## SearchBar (`widgets/search-bar/ui/SearchBar.tsx`, public API: `widgets/search-bar`)
Поиск инструментов с debounce (300ms). Поиск инструментов с debounce (300ms).
@ -16,7 +19,9 @@
- При клике на результат переходит на `/stocks/:secid` или `/bonds/:secid` - При клике на результат переходит на `/stocks/:secid` или `/bonds/:secid`
- Закрывается при клике вне компонента - Закрывается при клике вне компонента
## PriceChart (`components/PriceChart.tsx`) Legacy path `components/SearchBar.tsx` остаётся shim-файлом и не является primary entrypoint.
## PriceChart (`widgets/price-chart/ui/PriceChart.tsx`, public API: `widgets/price-chart`)
График цены на основе `lightweight-charts` v4. График цены на основе `lightweight-charts` v4.
@ -25,16 +30,30 @@
- Цвета: зелёный для роста, красный для падения - Цвета: зелёный для роста, красный для падения
- Адаптивная ширина (resize listener) - Адаптивная ширина (resize listener)
## StockDetails (`components/StockDetails.tsx`) Legacy path `components/PriceChart.tsx` остаётся shim-файлом.
## StockDetails (`widgets/stock-details/ui/StockDetails.tsx`, public API: `widgets/stock-details`)
Карточка с информацией об акции. Карточка с информацией об акции.
- Принимает `ShareResponse` - Принимает `ShareResponse`
- Отображает: название, тикер, ISIN, цена, изменение (%), open/high/low, объём, капитализация, уровень листинга - Отображает: название, тикер, ISIN, цена, изменение (%), open/high/low, объём, капитализация, уровень листинга
## BondDetails (`components/BondDetails.tsx`) Legacy path `components/StockDetails.tsx` остаётся shim-файлом.
## BondDetails (`widgets/bond-details/ui/BondDetails.tsx`, public API: `widgets/bond-details`)
Карточка с информацией об облигации. Карточка с информацией об облигации.
- Принимает `BondResponse` - Принимает `BondResponse`
- Отображает: название, ISIN, цена (в % от номинала), номинал, дата погашения, купон (сумма/%), период купона, НКД, доходность к погашению, дюрация, тип - Отображает: название, ISIN, цена (в % от номинала), номинал, дата погашения, купон (сумма/%), период купона, НКД, доходность к погашению, дюрация, тип
Legacy path `components/BondDetails.tsx` остаётся shim-файлом.
## DividendsTable (`widgets/dividends-table/ui/DividendsTable.tsx`, public API: `widgets/dividends-table`)
Таблица дивидендов для страницы акции.
- Принимает готовый список дивидендов через props
- Рендерит payout-историю без собственных query-вызовов
- Используется page-layer как prop-driven widget

View File

@ -4,13 +4,21 @@
| Хук | Query key | Stale time | Описание | | Хук | Query key | Stale time | Описание |
|---|---|---|---| |---|---|---|---|
| `useSearch(query)` | `['securities', 'search', query]` | 60s | Поиск инструментов (enabled: query ≥ 2 символов) | | `useSearch(query)` | `['securities', 'search', query]` | 60s | Поиск инструментов (primary: `entities/search/model/useSearch.ts`) |
| `useStock(secid)` | `['stock', secid]` | 900s | Спецификация акции | | `useStock(secid)` | `['stock', secid]` | 900s | Спецификация акции |
| `useStockCandles(secid, interval, from, till)` | `['stockCandles', secid, interval, from, till]` | 3600s | Свечи акции | | `useStockCandles(secid, interval, from, till)` | `['stockCandles', secid, interval, from, till]` | 3600s | Свечи акции |
| `useStockDividends(secid)` | `['stockDividends', secid]` | 86400s | Дивиденды акции | | `useStockDividends(secid)` | `['stockDividends', secid]` | 86400s | Дивиденды акции |
| `useBond(secid)` | `['bond', secid]` | 900s | Спецификация облигации | | `useBond(secid)` | `['bond', secid]` | 900s | Спецификация облигации |
| `useBondCandles(secid, interval, from, till)` | `['bondCandles', secid, interval, from, till]` | 3600s | Свечи облигации | | `useBondCandles(secid, interval, from, till)` | `['bondCandles', secid, interval, from, till]` | 3600s | Свечи облигации |
## Primary и legacy paths
- `useSearch` primary path: `apps/frontend/src/entities/search/model/useSearch.ts`
- Public API для search slice: `apps/frontend/src/entities/search/index.ts`
- Legacy compatibility path: `apps/frontend/src/hooks/useSearch.ts` — это shim, который
реэкспортирует primary hook
- Market pages используют `entities/search` как source of truth, а не historical hook path
## Конфигурация Query ## Конфигурация Query
```typescript ```typescript
@ -27,11 +35,19 @@ const queryClient = new QueryClient({
## Паттерн hook ## Паттерн hook
Каждый хук: Для historical hooks и FSD hooks общий принцип один и тот же:
1. Вызывает функцию из `api/client.ts` 1. Хук вызывает ближайший domain/shared API helper
2. Извлекает `res.data` (ответ MOEX обёрнут в `{ data, meta }`) 2. Извлекает `res.data` (ответ MOEX обёрнут в `{ data, meta }`)
3. Типизирован через рукописные типы из `api/responses.ts` 3. Типизируется через актуальные response types из `shared/api/responses.ts`
В legacy-слое helper может приходить из historical `api/*`, а в FSD-срезах — из domain API
файлов вроде `entities/stock/api/stockApi.ts` или `entities/bond/api/bondApi.ts`.
Для FSD-срезов domain-specific hooks постепенно переезжают ближе к своим сущностям. Для market
pages это уже сделано для `entities/search`, `entities/stock` и `entities/bond`; для portfolio
домен уже использует `entities/portfolio`; legacy imports сохраняются только как transitional
shim-слой там, где миграция ещё не завершена.
```typescript ```typescript
export function useStock(secid: string) { export function useStock(secid: string) {

View File

@ -14,6 +14,10 @@ React SPA, собранная с Vite.
## Структура исходников ## Структура исходников
Published-структура ниже описывает primary FSD entrypoints. Historical каталоги `api/`, `context/`,
`hooks/`, `components/` ещё присутствуют в кодовой базе, но для уже мигрированных областей они всё
чаще играют роль transitional shim-слоя, а не source of truth.
``` ```
apps/frontend/src/ apps/frontend/src/
├── main.tsx # Точка входа ├── main.tsx # Точка входа
@ -31,51 +35,35 @@ apps/frontend/src/
│ └── layouts/ │ └── layouts/
│ ├── AppLayout.tsx # Шапка + <Outlet/> │ ├── AppLayout.tsx # Шапка + <Outlet/>
│ └── index.ts │ └── index.ts
├── api/ ├── shared/
│ ├── auth.ts # re-export shim → entities/session/api │ ├── api/ # shared API client, response types, generated OpenAPI types
│ ├── client.ts # HTTP-клиент (fetch) │ └── ui/ # shared UI primitives without domain logic
│ ├── portfolio.ts # Portfolio API helpers
│ ├── responses.ts # Типы ответов (ручные)
│ ├── screener.ts # Screener API helpers
│ └── types.ts # Типы из openapi-typescript
├── context/
│ └── AuthContext.tsx # re-export shim → app/providers/SessionProvider
├── hooks/
│ ├── useAuth.ts # re-export shim → entities/session/model
│ ├── usePortfolio.ts
│ ├── usePortfolioAnalytics.ts
│ ├── usePortfolioMutations.ts
│ ├── usePortfolios.ts
│ ├── usePositionMutations.ts
│ ├── useScreener.ts
│ ├── useSearch.ts
│ ├── useStock.ts
│ ├── useStockCandles.ts
│ ├── useStockDividends.ts
│ ├── useBond.ts
│ └── useBondCandles.ts
├── components/
│ ├── Layout.tsx # re-export shim → app/layouts
│ ├── ProtectedRoute.tsx # re-export shim → app/routing
│ ├── SearchBar.tsx
│ ├── PriceChart.tsx
│ ├── StockDetails.tsx
│ ├── BondDetails.tsx
│ └── portfolios/
├── entities/ ├── entities/
│ ├── session/ # FSD: auth/session (api, model/context + hook) │ ├── session/ # FSD: auth/session (api, model/context + hook)
│ ├── search/ # FSD: market search query slice
│ ├── stock/ # FSD: stock api + query hooks
│ ├── bond/ # FSD: bond api + query hooks
│ ├── portfolio/ # FSD: portfolio api + query hooks
│ ├── broker-account/ # FSD: account slice (api, model, ui) │ ├── broker-account/ # FSD: account slice (api, model, ui)
│ ├── broker-position/ # FSD: position slice (api, model) │ ├── broker-position/ # FSD: position slice (api, model)
│ └── broker-operation/ # FSD: operation slice (api, model) │ └── broker-operation/ # FSD: operation slice (api, model)
├── widgets/ ├── widgets/
│ ├── search-bar/ # FSD: instrument search widget
│ ├── price-chart/ # FSD: market chart widget
│ ├── stock-details/ # FSD: stock details widget
│ ├── bond-details/ # FSD: bond details widget
│ ├── dividends-table/ # FSD: stock dividends widget
│ ├── broker-account-card/ # FSD: account card widget │ ├── broker-account-card/ # FSD: account card widget
│ ├── broker-accounts-summary/ # FSD: accounts summary widget │ ├── broker-accounts-summary/ # FSD: accounts summary widget
│ ├── broker-allocation-chart/ # FSD: allocation chart widget │ ├── broker-allocation-chart/ # FSD: allocation chart widget
│ └── broker-operations-table/ # FSD: operations table widget │ └── broker-operations-table/ # FSD: operations table widget
├── pages/ ├── pages/
│ ├── HomePage.tsx │ ├── HomePage.tsx # re-export shim → pages/home
│ ├── StockPage.tsx │ ├── StockPage.tsx # re-export shim → pages/stock
│ ├── BondPage.tsx │ ├── BondPage.tsx # re-export shim → pages/bond
│ ├── home/ # FSD: home page entrypoint
│ ├── stock/ # FSD: stock page entrypoint
│ ├── bond/ # FSD: bond page entrypoint
│ ├── LoginPage.tsx │ ├── LoginPage.tsx
│ ├── RegisterPage.tsx │ ├── RegisterPage.tsx
│ ├── ProfilePage.tsx │ ├── ProfilePage.tsx
@ -88,6 +76,10 @@ apps/frontend/src/
├── test/ ├── test/
│ ├── handlers.ts # MSW handlers │ ├── handlers.ts # MSW handlers
│ └── test-utils.tsx │ └── test-utils.tsx
├── api/ # historical paths + compatibility shims around shared/entities
├── context/ # historical compatibility layer
├── hooks/ # historical compatibility layer
└── components/ # historical compatibility layer
└── styles.css └── styles.css
``` ```
@ -117,6 +109,37 @@ Broker-домен переведён в пилотный FSD срез:
- `widgets/broker-*` — screen-level композиция - `widgets/broker-*` — screen-level композиция
- `entities/broker-*` — доменные срезы (api, model, ui) - `entities/broker-*` — доменные срезы (api, model, ui)
### market pages (следующая итерация)
После broker pilot market-раздел также переведён на FSD entrypoints:
- `pages/home`, `pages/stock`, `pages/bond` — primary route entrypoints;
- `widgets/search-bar`, `widgets/price-chart`, `widgets/stock-details`,
`widgets/bond-details`, `widgets/dividends-table` — primary UI entrypoints;
- `entities/search` — search query slice;
- `entities/stock` и `entities/bond` — query hooks и domain API для market pages.
При этом orchestration остаётся в page-layer: route params, вычисление диапазонов дат, загрузка
данных и loading/not-found состояния не переносятся в widgets.
### Остальные домены ### Остальные домены
`portfolio`, `screener` пока остаются в исторической технической структуре. `screener` в основном остаётся в historical технической структуре. `portfolio` находится в
промежуточном состоянии: page-level структура ещё не полностью унифицирована, но domain API/model
слой уже вынесен в `entities/portfolio`.
### Legacy coexistence
После app/session, broker и market migration в проекте ещё остаются top-level `api/`, `context/`,
`hooks/`, `components/` и плоские `pages/*.tsx`. Для уже мигрированных зон они служат главным
образом как compatibility shim-слой:
- `components/SearchBar.tsx``widgets/search-bar`
- `components/PriceChart.tsx``widgets/price-chart`
- `components/StockDetails.tsx``widgets/stock-details`
- `components/BondDetails.tsx``widgets/bond-details`
- `hooks/useSearch.ts``entities/search`
- `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx` → новые page entrypoints
Published docs описывают primary FSD entrypoints; legacy paths остаются временной поверхностью
совместимости.

View File

@ -1,20 +1,31 @@
# Маршруты # Маршруты
Определены в `apps/frontend/src/routes.tsx`. Source of truth для маршрутов: `apps/frontend/src/app/routing/AppRoutes.tsx`.
| Path | Component | Доступ | Описание | | Path | Component | Доступ | Описание |
|---|---|---|---| |---|---|---|---|
| `/` | `HomePage` | Public | Главная страница | | `/` | `HomePage` from `pages/home` | Public | Главная страница |
| `/stocks/:secid` | `StockPage` | Public | Страница акции | | `/stocks/:secid` | `StockPage` from `pages/stock` | Public | Страница акции |
| `/bonds/:secid` | `BondPage` | Public | Страница облигации | | `/bonds/:secid` | `BondPage` from `pages/bond` | Public | Страница облигации |
| `/screener` | `ScreenerPage` | Public | Скринер ценных бумаг | | `/screener` | `ScreenerPage` | Public | Скринер ценных бумаг |
| `/login` | `LoginPage` | Public | Вход | | `/login` | `LoginPage` | Public | Вход |
| `/register` | `RegisterPage` | Public | Регистрация | | `/register` | `RegisterPage` | Public | Регистрация |
| `/profile` | `ProfilePage` | Protected | Профиль текущего пользователя | | `/profile` | `ProfilePage` | Protected | Профиль текущего пользователя |
| `/portfolios` | `PortfoliosListPage` | Protected | Список портфелей | | `/portfolios` | `PortfoliosListPage` | Protected | Список портфелей |
| `/portfolios/:id` | `PortfolioDetailPage` | Protected | Детальная страница портфеля | | `/portfolios/:id` | `PortfolioDetailPage` | Protected | Детальная страница портфеля |
| `/broker` | `BrokerAccountsPage` from `pages/broker-accounts` | Protected | Список брокерских счетов |
| `/broker/:accountId` | `BrokerAccountLayout` + nested pages | Protected | Детальная область брокерского счёта |
Все страницы обёрнуты в `Layout`, который содержит: Market route entrypoints теперь живут в:
- `apps/frontend/src/pages/home`
- `apps/frontend/src/pages/stock`
- `apps/frontend/src/pages/bond`
Legacy `pages/HomePage.tsx`, `pages/StockPage.tsx`, `pages/BondPage.tsx` сохранены как shim-файлы,
но не являются source of truth для новых импортов.
Все страницы обёрнуты в `AppLayout`, который содержит:
- Шапку с логотипом (ссылка на `/`) и `SearchBar` - Шапку с логотипом (ссылка на `/`) и `SearchBar`
- `<main>` с максимальной шириной 1200px - `<main>` с максимальной шириной 1200px
@ -24,7 +35,7 @@
```tsx ```tsx
<Routes> <Routes>
<Route element={<Layout />}> <Route element={<AppLayout />}>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/stocks/:secid" element={<StockPage />} /> <Route path="/stocks/:secid" element={<StockPage />} />
<Route path="/bonds/:secid" element={<BondPage />} /> <Route path="/bonds/:secid" element={<BondPage />} />
@ -55,6 +66,27 @@
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="/broker"
element={
<ProtectedRoute>
<BrokerAccountsPage />
</ProtectedRoute>
}
/>
<Route
path="/broker/:accountId"
element={
<ProtectedRoute>
<BrokerAccountLayout />
</ProtectedRoute>
}
>
<Route index element={<BrokerAccountOverviewPage />} />
<Route path="shares" element={<BrokerPositionsPage type="share" title="Акции" />} />
<Route path="bonds" element={<BrokerPositionsPage type="bond" title="Облигации" />} />
<Route path="operations" element={<BrokerOperationsPage />} />
</Route>
</Route> </Route>
</Routes> </Routes>
``` ```

View File

@ -1,5 +1,5 @@
import { Outlet, Link, useNavigate } from 'react-router-dom'; import { Outlet, Link, useNavigate } from 'react-router-dom';
import { SearchBar } from '@/components/SearchBar'; import { SearchBar } from '@/widgets/search-bar';
import { useSession } from '@/entities/session/model/useSession'; import { useSession } from '@/entities/session/model/useSession';
export function AppLayout() { export function AppLayout() {

View File

@ -1,9 +1,9 @@
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import { AppLayout } from '../layouts/AppLayout'; import { AppLayout } from '../layouts/AppLayout';
import { ProtectedRoute } from './ProtectedRoute'; import { ProtectedRoute } from './ProtectedRoute';
import { HomePage } from '@/pages/HomePage'; import { HomePage } from '@/pages/home';
import { StockPage } from '@/pages/StockPage'; import { StockPage } from '@/pages/stock';
import { BondPage } from '@/pages/BondPage'; import { BondPage } from '@/pages/bond';
import { LoginPage } from '@/pages/LoginPage'; import { LoginPage } from '@/pages/LoginPage';
import { RegisterPage } from '@/pages/RegisterPage'; import { RegisterPage } from '@/pages/RegisterPage';
import { ProfilePage } from '@/pages/ProfilePage'; import { ProfilePage } from '@/pages/ProfilePage';

View File

@ -1,83 +1 @@
import type { BondResponse } from '@/shared/api/responses'; export { BondDetails } from '@/widgets/bond-details';
interface BondDetailsProps {
bond: BondResponse;
}
const rowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: '1px solid #eee',
};
export function BondDetails({ bond }: BondDetailsProps) {
const md = bond.marketData;
return (
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<div style={{ marginBottom: 16 }}>
<h2 style={{ fontSize: 28, fontWeight: 700 }}>{bond.shortName}</h2>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>{bond.isin}</div>
</div>
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
{md.price?.toFixed(2) ?? '—'}%
</div>
<div style={{ marginTop: 16 }}>
<div style={rowStyle}>
<span>Номинал</span>
<span>
{bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit}
</span>
</div>
<div style={rowStyle}>
<span>Дата погашения</span>
<span>{bond.matDate}</span>
</div>
<div style={rowStyle}>
<span>Купон</span>
<span>
{md.couponValue ?? '—'} {md.couponPercent != null ? `(${md.couponPercent}%)` : ''}
</span>
</div>
<div style={rowStyle}>
<span>Период купона</span>
<span>{bond.couponPeriod} дней</span>
</div>
<div style={rowStyle}>
<span>Следующий купон</span>
<span>{md.nextCouponDate ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>НКД</span>
<span>{md.accruedInt?.toFixed(2) ?? '—'} </span>
</div>
<div style={rowStyle}>
<span>Доходность к погашению</span>
<span>{md.yieldToMaturity != null ? md.yieldToMaturity.toFixed(2) + '%' : '—'}</span>
</div>
<div style={rowStyle}>
<span>Дюрация</span>
<span>{md.duration != null ? md.duration.toFixed(2) : '—'}</span>
</div>
<div style={rowStyle}>
<span>Тип</span>
<span>{bond.bondType}</span>
</div>
<div style={rowStyle}>
<span>ISIN</span>
<span>{bond.isin}</span>
</div>
</div>
</div>
);
}

View File

@ -1,71 +1 @@
import { useEffect, useRef } from 'react'; export { PriceChart } from '@/widgets/price-chart';
import { createChart, ColorType, CandlestickData, Time } from 'lightweight-charts';
interface PriceChartProps {
data: Array<{
open: number;
high: number;
low: number;
close: number;
begin: string;
}>;
height?: number;
}
export function PriceChart({ data, height = 400 }: PriceChartProps) {
const chartContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!chartContainerRef.current) return;
const chart = createChart(chartContainerRef.current, {
layout: {
background: { type: ColorType.Solid, color: '#ffffff' },
textColor: '#333',
},
width: chartContainerRef.current.clientWidth,
height,
grid: {
vertLines: { color: '#f0f0f0' },
horzLines: { color: '#f0f0f0' },
},
timeScale: {
timeVisible: false,
},
});
const candleSeries = chart.addCandlestickSeries({
upColor: '#2e7d32',
downColor: '#c62828',
borderDownColor: '#c62828',
borderUpColor: '#2e7d32',
wickDownColor: '#c62828',
wickUpColor: '#2e7d32',
});
const chartData: CandlestickData[] = data.map((candle) => ({
time: (new Date(candle.begin).getTime() / 1000) as Time,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
}));
candleSeries.setData(chartData);
chart.timeScale().fitContent();
const handleResize = () => {
if (chartContainerRef.current) {
chart.applyOptions({ width: chartContainerRef.current.clientWidth });
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.remove();
};
}, [data, height]);
return <div ref={chartContainerRef} />;
}

View File

@ -1,108 +1 @@
import { useState, useRef, useEffect } from 'react'; export { SearchBar } from '@/widgets/search-bar';
import { useNavigate } from 'react-router-dom';
import { useSearch } from '../hooks/useSearch';
export function SearchBar() {
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const id = setTimeout(() => setDebounced(query), 300);
return () => clearTimeout(id);
}, [query]);
const { data: results, isLoading } = useSearch(debounced);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
const showResults = open && debounced.length >= 2;
return (
<div ref={ref} style={{ position: 'relative', width: 400, maxWidth: '100%' }}>
<input
type="text"
placeholder="Поиск акций и облигаций..."
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 6,
fontSize: 14,
}}
/>
{showResults && (
<ul
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
background: '#fff',
border: '1px solid #ddd',
borderRadius: 6,
marginTop: 4,
padding: 0,
listStyle: 'none',
zIndex: 100,
maxHeight: 360,
overflowY: 'auto',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
>
{isLoading && <li style={{ padding: 12, color: '#888' }}>Загрузка...</li>}
{!isLoading && results && results.length === 0 && (
<li style={{ padding: 12, color: '#888' }}>Ничего не найдено</li>
)}
{!isLoading &&
results?.map((item) => (
<li
key={item.secid}
onClick={() => {
setOpen(false);
setQuery('');
navigate(
item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`,
);
}}
style={{
padding: '10px 12px',
cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
onMouseLeave={(e) => (e.currentTarget.style.background = '')}
>
<span>
<strong>{item.shortName}</strong>
<span style={{ marginLeft: 8, color: '#888', fontSize: 12 }}>{item.secid}</span>
</span>
<span
style={{ fontSize: 12, color: item.type === 'share' ? '#1976d2' : '#2e7d32' }}
>
{item.type === 'share' ? 'Акция' : 'Облигация'}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -1,83 +1 @@
import type { ShareResponse } from '@/shared/api/responses'; export { StockDetails } from '@/widgets/stock-details';
interface StockDetailsProps {
stock: ShareResponse;
}
const rowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: '1px solid #eee',
};
export function StockDetails({ stock }: StockDetailsProps) {
const md = stock.marketData;
const isPositive = (md.change ?? 0) >= 0;
return (
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<div style={{ marginBottom: 16 }}>
<h2 style={{ fontSize: 28, fontWeight: 700 }}>
{stock.shortName} ({stock.secid})
</h2>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
{stock.name} &middot; {stock.isin}
</div>
</div>
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
{md.price?.toLocaleString('ru-RU', { minimumFractionDigits: 2 }) ?? '—'}{' '}
<span
style={{
fontSize: 18,
color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)',
}}
>
{isPositive ? '+' : ''}
{(md.change ?? 0).toFixed(2)} ({(md.changePercent ?? 0).toFixed(2)}%)
</span>
</div>
<div style={{ marginTop: 16 }}>
<div style={rowStyle}>
<span>Открытие</span>
<span>{md.open?.toFixed(2) ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>Максимум</span>
<span>{md.high?.toFixed(2) ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>Минимум</span>
<span>{md.low?.toFixed(2) ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>Объём</span>
<span>{md.volume.toLocaleString('ru-RU')}</span>
</div>
<div style={rowStyle}>
<span>Капитализация</span>
<span>
{md.issueCapitalization ? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' : '—'}
</span>
</div>
<div style={rowStyle}>
<span>ISIN</span>
<span>{stock.isin}</span>
</div>
<div style={rowStyle}>
<span>Уровень листинга</span>
<span>{stock.listLevel}</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1 @@
export { useSearch } from './model/useSearch';

View File

@ -0,0 +1,72 @@
import { describe, it, expect } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { http, HttpResponse } from 'msw';
import { type ReactNode } from 'react';
import { server } from '@/test/server';
import { useSearch } from '@/entities/search';
const API = '/api/v1';
function createWrapper() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return function Wrapper({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}
describe('useSearch', () => {
it('does not fetch when query is empty', () => {
const { result } = renderHook(() => useSearch(''), { wrapper: createWrapper() });
expect(result.current.isFetching).toBe(false);
expect(result.current.data).toBeUndefined();
});
it('does not fetch when query is too short', () => {
const { result } = renderHook(() => useSearch('a'), { wrapper: createWrapper() });
expect(result.current.data).toBeUndefined();
});
it('returns search results for valid query', async () => {
const { result } = renderHook(() => useSearch('sber'), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toBeDefined();
expect(result.current.data?.length).toBeGreaterThan(0);
expect(result.current.data?.[0].secid).toBe('SBER');
});
it('returns empty array when no results', async () => {
server.use(
http.get(`${API}/securities/search`, () =>
HttpResponse.json({
data: { data: [], meta: { fromCache: false, cachedAt: null } },
}),
),
);
const { result } = renderHook(() => useSearch('zzzzz'), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual([]);
});
it('returns error state on network failure', async () => {
server.use(http.get(`${API}/securities/search`, () => new HttpResponse(null, { status: 500 })));
const { result } = renderHook(() => useSearch('error'), { wrapper: createWrapper() });
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
});
});

View File

@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query';
import { searchSecurities } from '@/shared/api/client';
import type { SearchResultItem } from '@/shared/api/responses';
export function useSearch(query: string) {
return useQuery<SearchResultItem[]>({
queryKey: ['securities', 'search', query],
queryFn: async () => {
const res = await searchSecurities(query);
return res.data;
},
enabled: query.length >= 2,
staleTime: 60_000,
});
}

View File

@ -1,15 +1 @@
import { useQuery } from '@tanstack/react-query'; export { useSearch } from '@/entities/search';
import { searchSecurities } from '@/shared/api/client';
import type { SearchResultItem } from '@/shared/api/responses';
export function useSearch(query: string) {
return useQuery<SearchResultItem[]>({
queryKey: ['securities', 'search', query],
queryFn: async () => {
const res = await searchSecurities(query);
return res.data;
},
enabled: query.length >= 2,
staleTime: 60_000,
});
}

View File

@ -3,7 +3,7 @@ import { screen } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../test/server'; import { server } from '../test/server';
import { BondPage } from './BondPage'; import { BondPage } from '@/pages/bond';
import { renderWithProviders } from '../test/test-utils'; import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1'; const API = '/api/v1';

View File

@ -1,33 +1 @@
import { useParams } from 'react-router-dom'; export { BondPage } from '@/pages/bond';
import { useBond, useBondCandles } from '../entities/bond';
import { BondDetails } from '../components/BondDetails';
import { PriceChart } from '../components/PriceChart';
export function BondPage() {
const { secid } = useParams<{ secid: string }>();
const { data: bond, isLoading, error } = useBond(secid!);
const till = new Date().toISOString().split('T')[0];
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const { data: candles } = useBondCandles(secid!, '24h', from, till);
if (isLoading) return <div>Загрузка...</div>;
if (error || !bond) return <div>Инструмент не найден</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<BondDetails bond={bond} />
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>График цены</h3>
<PriceChart data={candles ?? []} />
</div>
</div>
);
}

View File

@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import { HomePage } from './HomePage'; import { HomePage } from '@/pages/home';
describe('HomePage', () => { describe('HomePage', () => {
it('renders welcome title', () => { it('renders welcome title', () => {

View File

@ -1,14 +1 @@
export function HomePage() { export { HomePage } from '@/pages/home';
return (
<div style={{ textAlign: 'center', paddingTop: 120 }}>
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 12 }}>MoexVibe</h1>
<p style={{ color: 'var(--color-text-secondary)', fontSize: 16, marginBottom: 32 }}>
Анализ акций и облигаций Московской биржи
</p>
<p style={{ color: '#888', fontSize: 13 }}>Введите название или тикер в строку поиска выше</p>
<p style={{ color: '#aaa', fontSize: 12, marginTop: 8 }}>
Данные задерживаются на 15 минут &middot; Бесплатный API MOEX ISS
</p>
</div>
);
}

View File

@ -3,7 +3,7 @@ import { screen, waitFor } from '@testing-library/react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
import { http, HttpResponse } from 'msw'; import { http, HttpResponse } from 'msw';
import { server } from '../test/server'; import { server } from '../test/server';
import { StockPage } from './StockPage'; import { StockPage } from '@/pages/stock';
import { renderWithProviders } from '../test/test-utils'; import { renderWithProviders } from '../test/test-utils';
const API = '/api/v1'; const API = '/api/v1';

View File

@ -1,65 +1 @@
import { useParams } from 'react-router-dom'; export { StockPage } from '@/pages/stock';
import { useStock, useStockCandles, useStockDividends } from '../entities/stock';
import { StockDetails } from '../components/StockDetails';
import { PriceChart } from '../components/PriceChart';
export function StockPage() {
const { secid } = useParams<{ secid: string }>();
const { data: stock, isLoading, error } = useStock(secid!);
const till = new Date().toISOString().split('T')[0];
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const { data: candles } = useStockCandles(secid!, '24h', from, till);
const { data: dividends } = useStockDividends(secid!);
if (isLoading) return <div>Загрузка...</div>;
if (error || !stock) return <div>Инструмент не найден</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<StockDetails stock={stock} />
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>График цены</h3>
<PriceChart data={candles ?? []} />
</div>
{dividends && dividends.length > 0 && (
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #eee' }}>
<th style={{ textAlign: 'left', padding: 8 }}>Дата закрытия реестра</th>
<th style={{ textAlign: 'right', padding: 8 }}>Сумма</th>
</tr>
</thead>
<tbody>
{dividends.map((d, i) => (
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{d.registryCloseDate}</td>
<td style={{ textAlign: 'right', padding: 8 }}>
{d.value.toFixed(2)} {d.currency}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@ -0,0 +1 @@
export { BondPage } from './ui/BondPage';

View File

@ -0,0 +1,33 @@
import { useParams } from 'react-router-dom';
import { useBond, useBondCandles } from '@/entities/bond';
import { BondDetails } from '@/widgets/bond-details';
import { PriceChart } from '@/widgets/price-chart';
export function BondPage() {
const { secid } = useParams<{ secid: string }>();
const { data: bond, isLoading, error } = useBond(secid!);
const till = new Date().toISOString().split('T')[0];
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const { data: candles } = useBondCandles(secid!, '24h', from, till);
if (isLoading) return <div>Загрузка...</div>;
if (error || !bond) return <div>Инструмент не найден</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<BondDetails bond={bond} />
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>График цены</h3>
<PriceChart data={candles ?? []} />
</div>
</div>
);
}

View File

@ -0,0 +1 @@
export { HomePage } from './ui/HomePage';

View File

@ -0,0 +1,14 @@
export function HomePage() {
return (
<div style={{ textAlign: 'center', paddingTop: 120 }}>
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 12 }}>MoexVibe</h1>
<p style={{ color: 'var(--color-text-secondary)', fontSize: 16, marginBottom: 32 }}>
Анализ акций и облигаций Московской биржи
</p>
<p style={{ color: '#888', fontSize: 13 }}>Введите название или тикер в строку поиска выше</p>
<p style={{ color: '#aaa', fontSize: 12, marginTop: 8 }}>
Данные задерживаются на 15 минут &middot; Бесплатный API MOEX ISS
</p>
</div>
);
}

View File

@ -0,0 +1 @@
export { StockPage } from './ui/StockPage';

View File

@ -0,0 +1,37 @@
import { useParams } from 'react-router-dom';
import { useStock, useStockCandles, useStockDividends } from '@/entities/stock';
import { StockDetails } from '@/widgets/stock-details';
import { PriceChart } from '@/widgets/price-chart';
import { DividendsTable } from '@/widgets/dividends-table';
export function StockPage() {
const { secid } = useParams<{ secid: string }>();
const { data: stock, isLoading, error } = useStock(secid!);
const till = new Date().toISOString().split('T')[0];
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const { data: candles } = useStockCandles(secid!, '24h', from, till);
const { data: dividends } = useStockDividends(secid!);
if (isLoading) return <div>Загрузка...</div>;
if (error || !stock) return <div>Инструмент не найден</div>;
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<StockDetails stock={stock} />
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>График цены</h3>
<PriceChart data={candles ?? []} />
</div>
{dividends && dividends.length > 0 && <DividendsTable dividends={dividends} />}
</div>
);
}

View File

@ -0,0 +1 @@
export { BondDetails } from './ui/BondDetails';

View File

@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { BondDetails } from './BondDetails';
import { createMockBond } from '@/test/factories';
describe('BondDetails', () => {
it('renders bond details', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('ОФЗ 26238')).toBeInTheDocument();
expect(screen.getAllByText('RU000A101XU7').length).toBe(2);
});
it('shows price as percentage', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('98.50%')).toBeInTheDocument();
});
it('renders maturity date', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('2027-05-15')).toBeInTheDocument();
});
it('shows coupon with percentage', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('36.9 ₽ (7.5%)')).toBeInTheDocument();
});
it('shows coupon without percentage when null', () => {
const bond = createMockBond({
marketData: {
...createMockBond().marketData,
couponPercent: null,
},
});
render(<BondDetails bond={bond} />);
expect(screen.getByText('36.9 ₽')).toBeInTheDocument();
});
it('shows dash for missing next coupon date', () => {
const bond = createMockBond({
marketData: {
...createMockBond().marketData,
nextCouponDate: null,
},
});
render(<BondDetails bond={bond} />);
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(1);
});
it('shows dash for null yieldToMaturity', () => {
const bond = createMockBond({
marketData: {
...createMockBond().marketData,
yieldToMaturity: null,
},
});
render(<BondDetails bond={bond} />);
expect(screen.getByText('—')).toBeInTheDocument();
});
it('preserves unit suffixes for missing values', () => {
const bond = createMockBond({
marketData: {
...createMockBond().marketData,
price: null,
couponValue: null,
couponPercent: 7.5,
accruedInt: null,
},
});
render(<BondDetails bond={bond} />);
expect(screen.getByText('—%')).toBeInTheDocument();
expect(screen.getByText('— ₽ (7.5%)')).toBeInTheDocument();
expect(screen.getByText('— ₽')).toBeInTheDocument();
});
it('shows bond type', () => {
const bond = createMockBond();
render(<BondDetails bond={bond} />);
expect(screen.getByText('ОФЗ')).toBeInTheDocument();
});
it('preserves percent suffix when price is missing', () => {
const bond = createMockBond({
marketData: {
...createMockBond().marketData,
price: null,
},
});
render(<BondDetails bond={bond} />);
expect(screen.getByText('—%')).toBeInTheDocument();
});
it('preserves currency and percentage formatting when coupon value is missing', () => {
const bond = createMockBond({
marketData: {
...createMockBond().marketData,
couponValue: null,
},
});
render(<BondDetails bond={bond} />);
expect(screen.getByText('Купон')).toBeInTheDocument();
expect(screen.getByText('— ₽ (7.5%)')).toBeInTheDocument();
});
it('preserves currency suffix when accrued interest is missing', () => {
const bond = createMockBond({
marketData: {
...createMockBond().marketData,
accruedInt: null,
},
});
render(<BondDetails bond={bond} />);
expect(screen.getByText('НКД')).toBeInTheDocument();
expect(screen.getByText('— ₽')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,83 @@
import type { BondResponse } from '@/shared/api/responses';
interface BondDetailsProps {
bond: BondResponse;
}
const rowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: '1px solid #eee',
};
export function BondDetails({ bond }: BondDetailsProps) {
const md = bond.marketData;
return (
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<div style={{ marginBottom: 16 }}>
<h2 style={{ fontSize: 28, fontWeight: 700 }}>{bond.shortName}</h2>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>{bond.isin}</div>
</div>
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
{md.price?.toFixed(2) ?? '—'}%
</div>
<div style={{ marginTop: 16 }}>
<div style={rowStyle}>
<span>Номинал</span>
<span>
{bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit}
</span>
</div>
<div style={rowStyle}>
<span>Дата погашения</span>
<span>{bond.matDate}</span>
</div>
<div style={rowStyle}>
<span>Купон</span>
<span>
{md.couponValue ?? '—'} {md.couponPercent != null ? `(${md.couponPercent}%)` : ''}
</span>
</div>
<div style={rowStyle}>
<span>Период купона</span>
<span>{bond.couponPeriod} дней</span>
</div>
<div style={rowStyle}>
<span>Следующий купон</span>
<span>{md.nextCouponDate ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>НКД</span>
<span>{md.accruedInt?.toFixed(2) ?? '—'} </span>
</div>
<div style={rowStyle}>
<span>Доходность к погашению</span>
<span>{md.yieldToMaturity != null ? md.yieldToMaturity.toFixed(2) + '%' : '—'}</span>
</div>
<div style={rowStyle}>
<span>Дюрация</span>
<span>{md.duration != null ? md.duration.toFixed(2) : '—'}</span>
</div>
<div style={rowStyle}>
<span>Тип</span>
<span>{bond.bondType}</span>
</div>
<div style={rowStyle}>
<span>ISIN</span>
<span>{bond.isin}</span>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1 @@
export { DividendsTable } from './ui/DividendsTable';

View File

@ -0,0 +1,15 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { DividendsTable } from './DividendsTable';
import { createMockDividends } from '@/test/factories';
describe('DividendsTable', () => {
it('renders title, date column and formatted amount with currency', () => {
render(<DividendsTable dividends={createMockDividends()} />);
expect(screen.getByText('Дивиденды')).toBeInTheDocument();
expect(screen.getByText('Дата закрытия реестра')).toBeInTheDocument();
expect(screen.getByText('2024-07-10')).toBeInTheDocument();
expect(screen.getByText('35.00 RUB')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,38 @@
import type { DividendItem } from '@/shared/api/responses';
interface DividendsTableProps {
dividends: DividendItem[];
}
export function DividendsTable({ dividends }: DividendsTableProps) {
return (
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>Дивиденды</h3>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #eee' }}>
<th style={{ textAlign: 'left', padding: 8 }}>Дата закрытия реестра</th>
<th style={{ textAlign: 'right', padding: 8 }}>Сумма</th>
</tr>
</thead>
<tbody>
{dividends.map((d, i) => (
<tr key={i} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: 8 }}>{d.registryCloseDate}</td>
<td style={{ textAlign: 'right', padding: 8 }}>
{d.value.toFixed(2)} {d.currency}
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@ -0,0 +1 @@
export { PriceChart } from './ui/PriceChart';

View File

@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { render } from '@testing-library/react';
import { PriceChart } from './PriceChart';
describe('PriceChart', () => {
it('renders chart container with empty data', () => {
const { container } = render(<PriceChart data={[]} />);
expect(container.querySelector('div')).toBeInTheDocument();
});
it('renders with candle data', () => {
const data = [{ open: 100, high: 110, low: 95, close: 105, begin: '2024-01-15T10:00:00Z' }];
const { container } = render(<PriceChart data={data} />);
expect(container.querySelector('div')).toBeInTheDocument();
});
it('accepts custom height', () => {
const { container } = render(<PriceChart data={[]} height={600} />);
expect(container.querySelector('div')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,71 @@
import { useEffect, useRef } from 'react';
import { createChart, ColorType, CandlestickData, Time } from 'lightweight-charts';
interface PriceChartProps {
data: Array<{
open: number;
high: number;
low: number;
close: number;
begin: string;
}>;
height?: number;
}
export function PriceChart({ data, height = 400 }: PriceChartProps) {
const chartContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!chartContainerRef.current) return;
const chart = createChart(chartContainerRef.current, {
layout: {
background: { type: ColorType.Solid, color: '#ffffff' },
textColor: '#333',
},
width: chartContainerRef.current.clientWidth,
height,
grid: {
vertLines: { color: '#f0f0f0' },
horzLines: { color: '#f0f0f0' },
},
timeScale: {
timeVisible: false,
},
});
const candleSeries = chart.addCandlestickSeries({
upColor: '#2e7d32',
downColor: '#c62828',
borderDownColor: '#c62828',
borderUpColor: '#2e7d32',
wickDownColor: '#c62828',
wickUpColor: '#2e7d32',
});
const chartData: CandlestickData[] = data.map((candle) => ({
time: (new Date(candle.begin).getTime() / 1000) as Time,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
}));
candleSeries.setData(chartData);
chart.timeScale().fitContent();
const handleResize = () => {
if (chartContainerRef.current) {
chart.applyOptions({ width: chartContainerRef.current.clientWidth });
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.remove();
};
}, [data, height]);
return <div ref={chartContainerRef} />;
}

View File

@ -0,0 +1 @@
export { SearchBar } from './ui/SearchBar';

View File

@ -0,0 +1,90 @@
import { describe, it, expect } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { server } from '@/test/server';
import { SearchBar } from '@/widgets/search-bar';
const API = '/api/v1';
function renderSearchBar() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<SearchBar />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe('SearchBar', () => {
it('renders search input', () => {
renderSearchBar();
expect(screen.getByPlaceholderText('Поиск акций и облигаций...')).toBeInTheDocument();
});
it('shows dropdown on focus', async () => {
renderSearchBar();
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
await userEvent.type(input, 'sber');
await waitFor(() => {
expect(screen.getByText('Сбер')).toBeInTheDocument();
});
});
it('shows loading state while fetching', async () => {
server.use(http.get(`${API}/securities/search`, () => new Promise(() => {})));
renderSearchBar();
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
await userEvent.type(input, 'sber');
await waitFor(() => {
expect(screen.getByText('Загрузка...')).toBeInTheDocument();
});
});
it('shows no results message', async () => {
server.use(
http.get(`${API}/securities/search`, () =>
HttpResponse.json({
data: { data: [], meta: { fromCache: false, cachedAt: null } },
}),
),
);
renderSearchBar();
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
await userEvent.type(input, 'zzzzz');
await waitFor(() => {
expect(screen.getByText('Ничего не найдено')).toBeInTheDocument();
});
});
it('hides dropdown when clicking outside', async () => {
renderSearchBar();
const input = screen.getByPlaceholderText('Поиск акций и облигаций...');
await userEvent.type(input, 'sber');
await waitFor(() => {
expect(screen.getByText('Сбер')).toBeInTheDocument();
});
await userEvent.click(document.body);
await waitFor(() => {
expect(screen.queryByText('Сбер')).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,113 @@
import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSearch } from '@/entities/search';
export function SearchBar() {
const [query, setQuery] = useState('');
const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false);
const navigate = useNavigate();
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const id = setTimeout(() => setDebounced(query), 300);
return () => clearTimeout(id);
}, [query]);
const { data: results, isLoading } = useSearch(debounced);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, []);
const showResults = open && debounced.length >= 2;
return (
<div ref={ref} style={{ position: 'relative', width: 400, maxWidth: '100%' }}>
<input
type="text"
placeholder="Поиск акций и облигаций..."
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
style={{
width: '100%',
padding: '8px 12px',
border: '1px solid #ccc',
borderRadius: 6,
fontSize: 14,
}}
/>
{showResults && (
<ul
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
background: '#fff',
border: '1px solid #ddd',
borderRadius: 6,
marginTop: 4,
padding: 0,
listStyle: 'none',
zIndex: 100,
maxHeight: 360,
overflowY: 'auto',
boxShadow: '0 4px 12px rgba(0,0,0,0.1)',
}}
>
{isLoading && <li style={{ padding: 12, color: '#888' }}>Загрузка...</li>}
{!isLoading && results && results.length === 0 && (
<li style={{ padding: 12, color: '#888' }}>Ничего не найдено</li>
)}
{!isLoading &&
results?.map((item) => (
<li
key={item.secid}
onClick={() => {
setOpen(false);
setQuery('');
navigate(
item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`,
);
}}
style={{
padding: '10px 12px',
cursor: 'pointer',
borderBottom: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = '#f5f5f5')}
onMouseLeave={(e) => (e.currentTarget.style.background = '')}
>
<span>
<strong>{item.shortName}</strong>
<span style={{ marginLeft: 8, color: '#888', fontSize: 12 }}>{item.secid}</span>
</span>
<span
style={{ fontSize: 12, color: item.type === 'share' ? '#1976d2' : '#2e7d32' }}
>
{item.type === 'share' ? 'Акция' : 'Облигация'}
</span>
</li>
))}
</ul>
)}
</div>
);
}

View File

@ -0,0 +1 @@
export { StockDetails } from './ui/StockDetails';

View File

@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { StockDetails } from './StockDetails';
import { createMockShare } from '@/test/factories';
describe('StockDetails', () => {
it('renders stock details', () => {
const stock = createMockShare();
render(<StockDetails stock={stock} />);
expect(screen.getByText('Сбер (SBER)')).toBeInTheDocument();
expect(screen.getByText('Сбер Банк · RU0009029540')).toBeInTheDocument();
});
it('displays positive change in green', () => {
const stock = createMockShare({
marketData: {
...createMockShare().marketData,
change: 5,
changePercent: 2,
},
});
render(<StockDetails stock={stock} />);
const changeEl = screen.getByText('+5.00 (2.00%)');
expect(changeEl).toBeInTheDocument();
});
it('displays negative change in red', () => {
const stock = createMockShare({
marketData: {
...createMockShare().marketData,
change: -3,
changePercent: -1,
},
});
render(<StockDetails stock={stock} />);
const changeEl = screen.getByText('-3.00 (-1.00%)');
expect(changeEl).toBeInTheDocument();
});
it('shows price formatted', () => {
const stock = createMockShare();
render(<StockDetails stock={stock} />);
expect(screen.getByText('289,50')).toBeInTheDocument();
});
it('shows dash for null high', () => {
const stock = createMockShare({
marketData: {
...createMockShare().marketData,
high: null,
},
});
render(<StockDetails stock={stock} />);
const dashes = screen.getAllByText('—');
expect(dashes.length).toBeGreaterThanOrEqual(1);
});
it('shows capitalization in billions', () => {
const stock = createMockShare();
render(<StockDetails stock={stock} />);
expect(screen.getByText('6250.00 млрд ₽')).toBeInTheDocument();
});
it('falls back to zero change when change data is missing', () => {
const stock = createMockShare({
marketData: {
...createMockShare().marketData,
change: null,
changePercent: null,
},
});
render(<StockDetails stock={stock} />);
expect(screen.getByText('289,50')).toBeInTheDocument();
expect(screen.getByText('+0.00 (0.00%)')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,85 @@
import type { ShareResponse } from '@/shared/api/responses';
interface StockDetailsProps {
stock: ShareResponse;
}
const rowStyle: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
padding: '8px 0',
borderBottom: '1px solid #eee',
};
export function StockDetails({ stock }: StockDetailsProps) {
const md = stock.marketData;
const change = md.change ?? 0;
const changePercent = md.changePercent ?? 0;
const isPositive = change >= 0;
const changeLabel = `${isPositive ? '+' : ''}${change.toFixed(2)} (${changePercent.toFixed(2)}%)`;
return (
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<div style={{ marginBottom: 16 }}>
<h2 style={{ fontSize: 28, fontWeight: 700 }}>
{stock.shortName} ({stock.secid})
</h2>
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)' }}>
{stock.name} &middot; {stock.isin}
</div>
</div>
<div style={{ fontSize: 36, fontWeight: 700, marginBottom: 4 }}>
{md.price?.toLocaleString('ru-RU', { minimumFractionDigits: 2 }) ?? '—'}{' '}
<span
style={{
fontSize: 18,
color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)',
}}
>
{changeLabel}
</span>
</div>
<div style={{ marginTop: 16 }}>
<div style={rowStyle}>
<span>Открытие</span>
<span>{md.open?.toFixed(2) ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>Максимум</span>
<span>{md.high?.toFixed(2) ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>Минимум</span>
<span>{md.low?.toFixed(2) ?? '—'}</span>
</div>
<div style={rowStyle}>
<span>Объём</span>
<span>{md.volume.toLocaleString('ru-RU')}</span>
</div>
<div style={rowStyle}>
<span>Капитализация</span>
<span>
{md.issueCapitalization ? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' : '—'}
</span>
</div>
<div style={rowStyle}>
<span>ISIN</span>
<span>{stock.isin}</span>
</div>
<div style={rowStyle}>
<span>Уровень листинга</span>
<span>{stock.listLevel}</span>
</div>
</div>
</div>
);
}