diff --git a/apps/frontend/src/components/BondDetails.tsx b/apps/frontend/src/components/BondDetails.tsx new file mode 100644 index 0000000..993fd58 --- /dev/null +++ b/apps/frontend/src/components/BondDetails.tsx @@ -0,0 +1,74 @@ +import type { BondResponse } from '../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 ( +
+
+

{bond.shortName}

+
+ {bond.isin} +
+
+ +
+ {md.price.toFixed(2)}% +
+ +
+
+ Номинал + {bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit} +
+
+ Дата погашения + {bond.matDate} +
+
+ Купон + {md.couponValue} ₽ {md.couponPercent != null ? `(${md.couponPercent}%)` : ''} +
+
+ Период купона + {bond.couponPeriod} дней +
+
+ Следующий купон + {md.nextCouponDate ?? '—'} +
+
+ НКД + {md.accruedInt.toFixed(2)} ₽ +
+
+ Доходность к погашению + {md.yieldToMaturity != null ? md.yieldToMaturity.toFixed(2) + '%' : '—'} +
+
+ Дюрация + {md.duration != null ? md.duration.toFixed(2) : '—'} +
+
+ Тип + {bond.bondType} +
+
+ ISIN + {bond.isin} +
+
+
+ ); +} diff --git a/apps/frontend/src/components/PriceChart.tsx b/apps/frontend/src/components/PriceChart.tsx new file mode 100644 index 0000000..4df9440 --- /dev/null +++ b/apps/frontend/src/components/PriceChart.tsx @@ -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(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
; +} diff --git a/apps/frontend/src/components/StockDetails.tsx b/apps/frontend/src/components/StockDetails.tsx new file mode 100644 index 0000000..d9423ee --- /dev/null +++ b/apps/frontend/src/components/StockDetails.tsx @@ -0,0 +1,72 @@ +import type { ShareResponse } from '../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 isPositive = md.change >= 0; + + return ( +
+
+

+ {stock.shortName} ({stock.secid}) +

+
+ {stock.name} · {stock.isin} +
+
+ +
+ {md.price.toLocaleString('ru-RU', { minimumFractionDigits: 2 })}{' '} + + {isPositive ? '+' : ''}{md.change.toFixed(2)} ({md.changePercent.toFixed(2)}%) + +
+ +
+
+ Открытие + {md.open.toFixed(2)} +
+
+ Максимум + {md.high?.toFixed(2) ?? '—'} +
+
+ Минимум + {md.low?.toFixed(2) ?? '—'} +
+
+ Объём + {md.volume.toLocaleString('ru-RU')} +
+
+ Капитализация + + {md.issueCapitalization + ? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' + : '—'} + +
+
+ ISIN + {stock.isin} +
+
+ Уровень листинга + {stock.listLevel} +
+
+
+ ); +} diff --git a/apps/frontend/src/hooks/useBond.ts b/apps/frontend/src/hooks/useBond.ts new file mode 100644 index 0000000..aa881ee --- /dev/null +++ b/apps/frontend/src/hooks/useBond.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import { getBond } from '../api/client'; +import type { BondResponse } from '../api/responses'; + +export function useBond(secid: string) { + return useQuery({ + queryKey: ['bond', secid], + queryFn: async () => { + const res = await getBond(secid); + return res.data; + }, + staleTime: 900_000, + }); +} diff --git a/apps/frontend/src/hooks/useBondCandles.ts b/apps/frontend/src/hooks/useBondCandles.ts new file mode 100644 index 0000000..6ddce5a --- /dev/null +++ b/apps/frontend/src/hooks/useBondCandles.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import { getBondCandles } from '../api/client'; +import type { CandleItem } from '../api/responses'; + +export function useBondCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { + return useQuery({ + queryKey: ['bondCandles', secid, interval, from, till], + queryFn: async () => { + const res = await getBondCandles(secid, interval, from, till); + return res.data; + }, + staleTime: 3600_000, + }); +} diff --git a/apps/frontend/src/hooks/useStock.ts b/apps/frontend/src/hooks/useStock.ts new file mode 100644 index 0000000..930bac1 --- /dev/null +++ b/apps/frontend/src/hooks/useStock.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import { getShare } from '../api/client'; +import type { ShareResponse } from '../api/responses'; + +export function useStock(secid: string) { + return useQuery({ + queryKey: ['stock', secid], + queryFn: async () => { + const res = await getShare(secid); + return res.data; + }, + staleTime: 900_000, + }); +} diff --git a/apps/frontend/src/hooks/useStockCandles.ts b/apps/frontend/src/hooks/useStockCandles.ts new file mode 100644 index 0000000..de62f71 --- /dev/null +++ b/apps/frontend/src/hooks/useStockCandles.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import { getShareCandles } from '../api/client'; +import type { CandleItem } from '../api/responses'; + +export function useStockCandles(secid: string, interval: '1h' | '24h', from: string, till: string) { + return useQuery({ + queryKey: ['stockCandles', secid, interval, from, till], + queryFn: async () => { + const res = await getShareCandles(secid, interval, from, till); + return res.data; + }, + staleTime: 3600_000, + }); +} diff --git a/apps/frontend/src/hooks/useStockDividends.ts b/apps/frontend/src/hooks/useStockDividends.ts new file mode 100644 index 0000000..cbdcb99 --- /dev/null +++ b/apps/frontend/src/hooks/useStockDividends.ts @@ -0,0 +1,14 @@ +import { useQuery } from '@tanstack/react-query'; +import { getShareDividends } from '../api/client'; +import type { DividendItem } from '../api/responses'; + +export function useStockDividends(secid: string) { + return useQuery({ + queryKey: ['stockDividends', secid], + queryFn: async () => { + const res = await getShareDividends(secid); + return res.data; + }, + staleTime: 86400_000, + }); +} diff --git a/apps/frontend/src/pages/BondPage.tsx b/apps/frontend/src/pages/BondPage.tsx index 29b5294..c6ef913 100644 --- a/apps/frontend/src/pages/BondPage.tsx +++ b/apps/frontend/src/pages/BondPage.tsx @@ -1,40 +1,26 @@ import { useParams } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; -import { getBond } from '../api/client'; +import { useBond } from '../hooks/useBond'; +import { useBondCandles } from '../hooks/useBondCandles'; +import { BondDetails } from '../components/BondDetails'; +import { PriceChart } from '../components/PriceChart'; export function BondPage() { const { secid } = useParams<{ secid: string }>(); - const { data, isLoading, error } = useQuery({ - queryKey: ['bonds', secid], - queryFn: () => getBond(secid!), - enabled: !!secid, - }); + 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
Загрузка...
; - if (error || !data) return
Ошибка загрузки данных
; + if (error || !bond) return
Инструмент не найден
; return ( -
-

{data.data.shortName}

-

- {data.data.secid} · {data.data.isin} -

-
-
- {data.data.marketData.price.toFixed(2)} ₽ -
-
- {data.data.marketData.yieldToMaturity !== null - ? `Доходность: ${data.data.marketData.yieldToMaturity.toFixed(2)}%` - : 'N/A'} -
+
+ + +
+

График цены

+
); diff --git a/apps/frontend/src/pages/StockPage.tsx b/apps/frontend/src/pages/StockPage.tsx index b9a6ce2..f7b44c0 100644 --- a/apps/frontend/src/pages/StockPage.tsx +++ b/apps/frontend/src/pages/StockPage.tsx @@ -1,47 +1,53 @@ import { useParams } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; -import { getShare } from '../api/client'; +import { useStock } from '../hooks/useStock'; +import { useStockCandles } from '../hooks/useStockCandles'; +import { useStockDividends } from '../hooks/useStockDividends'; +import { StockDetails } from '../components/StockDetails'; +import { PriceChart } from '../components/PriceChart'; export function StockPage() { const { secid } = useParams<{ secid: string }>(); - const { data, isLoading, error } = useQuery({ - queryKey: ['shares', secid], - queryFn: () => getShare(secid!), - enabled: !!secid, - }); + 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
Загрузка...
; - if (error || !data) return
Ошибка загрузки данных
; + if (error || !stock) return
Инструмент не найден
; return ( -
-

{data.data.shortName}

-

- {data.data.secid} · {data.data.isin} -

-
-
- {data.data.marketData.price.toFixed(2)} ₽ -
-
= 0 ? 'var(--color-positive)' : 'var(--color-negative)', - }} - > - {data.data.marketData.change >= 0 ? '+' : ''} - {data.data.marketData.change.toFixed(2)} /{' '} - {data.data.marketData.changePercent >= 0 ? '+' : ''} - {data.data.marketData.changePercent.toFixed(2)}% -
+
+ + +
+

График цены

+
+ + {dividends && dividends.length > 0 && ( +
+

Дивиденды

+ + + + + + + + + {dividends.map((d, i) => ( + + + + + ))} + +
Дата закрытия реестраСумма
{d.registryCloseDate} + {d.value.toFixed(2)} {d.currency} +
+
+ )}
); }