feat: Sprint 5 - frontend detail pages with charts
- Add useStock, useStockCandles, useStockDividends, useBond, useBondCandles hooks - Add StockDetails component with full share info - Add BondDetails component with full bond info (NKD, YTM, duration, coupon) - Add PriceChart component using lightweight-charts (candlestick chart) - Update StockPage and BondPage with details, charts, and dividends table
This commit is contained in:
parent
4b753266b3
commit
9ae3906971
74
apps/frontend/src/components/BondDetails.tsx
Normal file
74
apps/frontend/src/components/BondDetails.tsx
Normal file
@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
71
apps/frontend/src/components/PriceChart.tsx
Normal file
71
apps/frontend/src/components/PriceChart.tsx
Normal 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} />;
|
||||
}
|
||||
72
apps/frontend/src/components/StockDetails.tsx
Normal file
72
apps/frontend/src/components/StockDetails.tsx
Normal file
@ -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 (
|
||||
<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} · {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.toFixed(2)} ({md.changePercent.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>
|
||||
);
|
||||
}
|
||||
14
apps/frontend/src/hooks/useBond.ts
Normal file
14
apps/frontend/src/hooks/useBond.ts
Normal file
@ -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<BondResponse>({
|
||||
queryKey: ['bond', secid],
|
||||
queryFn: async () => {
|
||||
const res = await getBond(secid);
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 900_000,
|
||||
});
|
||||
}
|
||||
14
apps/frontend/src/hooks/useBondCandles.ts
Normal file
14
apps/frontend/src/hooks/useBondCandles.ts
Normal file
@ -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<CandleItem[]>({
|
||||
queryKey: ['bondCandles', secid, interval, from, till],
|
||||
queryFn: async () => {
|
||||
const res = await getBondCandles(secid, interval, from, till);
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 3600_000,
|
||||
});
|
||||
}
|
||||
14
apps/frontend/src/hooks/useStock.ts
Normal file
14
apps/frontend/src/hooks/useStock.ts
Normal file
@ -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<ShareResponse>({
|
||||
queryKey: ['stock', secid],
|
||||
queryFn: async () => {
|
||||
const res = await getShare(secid);
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 900_000,
|
||||
});
|
||||
}
|
||||
14
apps/frontend/src/hooks/useStockCandles.ts
Normal file
14
apps/frontend/src/hooks/useStockCandles.ts
Normal file
@ -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<CandleItem[]>({
|
||||
queryKey: ['stockCandles', secid, interval, from, till],
|
||||
queryFn: async () => {
|
||||
const res = await getShareCandles(secid, interval, from, till);
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 3600_000,
|
||||
});
|
||||
}
|
||||
14
apps/frontend/src/hooks/useStockDividends.ts
Normal file
14
apps/frontend/src/hooks/useStockDividends.ts
Normal file
@ -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<DividendItem[]>({
|
||||
queryKey: ['stockDividends', secid],
|
||||
queryFn: async () => {
|
||||
const res = await getShareDividends(secid);
|
||||
return res.data;
|
||||
},
|
||||
staleTime: 86400_000,
|
||||
});
|
||||
}
|
||||
@ -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 <div>Загрузка...</div>;
|
||||
if (error || !data) return <div>Ошибка загрузки данных</div>;
|
||||
if (error || !bond) return <div>Инструмент не найден</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data.data.shortName}</h1>
|
||||
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 16 }}>
|
||||
{data.data.secid} · {data.data.isin}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 28, fontWeight: 700 }}>
|
||||
{data.data.marketData.price.toFixed(2)} ₽
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', marginTop: 4 }}>
|
||||
{data.data.marketData.yieldToMaturity !== null
|
||||
? `Доходность: ${data.data.marketData.yieldToMaturity.toFixed(2)}%`
|
||||
: 'N/A'}
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
|
||||
@ -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 <div>Загрузка...</div>;
|
||||
if (error || !data) return <div>Ошибка загрузки данных</div>;
|
||||
if (error || !stock) return <div>Инструмент не найден</div>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>{data.data.shortName}</h1>
|
||||
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 16 }}>
|
||||
{data.data.secid} · {data.data.isin}
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 'var(--border-radius)',
|
||||
boxShadow: 'var(--shadow)',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 28, fontWeight: 700 }}>
|
||||
{data.data.marketData.price.toFixed(2)} ₽
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: data.data.marketData.change >= 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)}%
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user