MVP реализации MoexVibe — NestJS бэкенд + React фронтенд + CI/CD #1

Merged
ksv741 merged 21 commits from feat/mvp-implementation into main 2026-06-13 21:07:34 +03:00
10 changed files with 343 additions and 64 deletions
Showing only changes of commit 9ae3906971 - Show all commits

View 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>
);
}

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,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} &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.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>
);
}

View 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,
});
}

View 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,
});
}

View 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,
});
}

View 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,
});
}

View 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,
});
}

View File

@ -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} &middot; {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>
); );

View File

@ -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} &middot; {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>
); );
} }