MVP реализации MoexVibe — NestJS бэкенд + React фронтенд + CI/CD #1
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 { useParams } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useBond } from '../hooks/useBond';
|
||||||
import { getBond } from '../api/client';
|
import { useBondCandles } from '../hooks/useBondCandles';
|
||||||
|
import { BondDetails } from '../components/BondDetails';
|
||||||
|
import { PriceChart } from '../components/PriceChart';
|
||||||
|
|
||||||
export function BondPage() {
|
export function BondPage() {
|
||||||
const { secid } = useParams<{ secid: string }>();
|
const { secid } = useParams<{ secid: string }>();
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data: bond, isLoading, error } = useBond(secid!);
|
||||||
queryKey: ['bonds', secid],
|
const till = new Date().toISOString().split('T')[0];
|
||||||
queryFn: () => getBond(secid!),
|
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||||
enabled: !!secid,
|
const { data: candles } = useBondCandles(secid!, '24h', from, till);
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) return <div>Загрузка...</div>;
|
if (isLoading) return <div>Загрузка...</div>;
|
||||||
if (error || !data) return <div>Ошибка загрузки данных</div>;
|
if (error || !bond) return <div>Инструмент не найден</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<h1>{data.data.shortName}</h1>
|
<BondDetails bond={bond} />
|
||||||
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 16 }}>
|
|
||||||
{data.data.secid} · {data.data.isin}
|
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
|
||||||
</p>
|
<h3 style={{ marginBottom: 16 }}>График цены</h3>
|
||||||
<div
|
<PriceChart data={candles ?? []} />
|
||||||
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,47 +1,53 @@
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useStock } from '../hooks/useStock';
|
||||||
import { getShare } from '../api/client';
|
import { useStockCandles } from '../hooks/useStockCandles';
|
||||||
|
import { useStockDividends } from '../hooks/useStockDividends';
|
||||||
|
import { StockDetails } from '../components/StockDetails';
|
||||||
|
import { PriceChart } from '../components/PriceChart';
|
||||||
|
|
||||||
export function StockPage() {
|
export function StockPage() {
|
||||||
const { secid } = useParams<{ secid: string }>();
|
const { secid } = useParams<{ secid: string }>();
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data: stock, isLoading, error } = useStock(secid!);
|
||||||
queryKey: ['shares', secid],
|
const till = new Date().toISOString().split('T')[0];
|
||||||
queryFn: () => getShare(secid!),
|
const from = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
|
||||||
enabled: !!secid,
|
const { data: candles } = useStockCandles(secid!, '24h', from, till);
|
||||||
});
|
const { data: dividends } = useStockDividends(secid!);
|
||||||
|
|
||||||
if (isLoading) return <div>Загрузка...</div>;
|
if (isLoading) return <div>Загрузка...</div>;
|
||||||
if (error || !data) return <div>Ошибка загрузки данных</div>;
|
if (error || !stock) return <div>Инструмент не найден</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<h1>{data.data.shortName}</h1>
|
<StockDetails stock={stock} />
|
||||||
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 16 }}>
|
|
||||||
{data.data.secid} · {data.data.isin}
|
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
|
||||||
</p>
|
<h3 style={{ marginBottom: 16 }}>График цены</h3>
|
||||||
<div
|
<PriceChart data={candles ?? []} />
|
||||||
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>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user