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
8 changed files with 468 additions and 4 deletions
Showing only changes of commit 4b753266b3 - Show all commits

View File

@ -0,0 +1,103 @@
import type {
ApiEnvelope,
ApiResponseMeta,
ShareResponse,
StockMarketData,
DividendItem,
ShareHistoryItem,
BondResponse,
BondMarketData,
BondHistoryItem,
CandleItem,
SearchResultItem,
HealthResponse,
} from './responses';
const BASE = '';
async function request<T>(path: string, params?: Record<string, string>): Promise<{ data: T; meta: ApiResponseMeta }> {
const url = new URL(`${BASE}${path}`, window.location.origin);
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v !== undefined) url.searchParams.set(k, v);
}
}
const res = await fetch(url.toString());
if (!res.ok) throw new Error(`API error: ${res.status} ${res.statusText}`);
const json: ApiEnvelope<{ data: T; meta: ApiResponseMeta }> = await res.json();
return json.data;
}
export function getHealth(): Promise<{ data: HealthResponse; meta: ApiResponseMeta }> {
return request<HealthResponse>('/api/v1/health');
}
export function searchSecurities(
q: string,
type: 'all' | 'share' | 'bond' = 'all',
limit = 20,
): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> {
return request<SearchResultItem[]>('/api/v1/securities/search', { q, type, limit: String(limit) });
}
export function getShare(secid: string): Promise<{ data: ShareResponse; meta: ApiResponseMeta }> {
return request<ShareResponse>(`/api/v1/securities/shares/${encodeURIComponent(secid)}`);
}
export function getShareMarketData(secid: string): Promise<{ data: StockMarketData; meta: ApiResponseMeta }> {
return request<StockMarketData>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/marketdata`);
}
export function getShareDividends(secid: string): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> {
return request<DividendItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`);
}
export function getShareHistory(
secid: string,
from: string,
till: string,
): Promise<{ data: ShareHistoryItem[]; meta: ApiResponseMeta }> {
return request<ShareHistoryItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`, { from, till });
}
export function getBond(secid: string): Promise<{ data: BondResponse; meta: ApiResponseMeta }> {
return request<BondResponse>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}`);
}
export function getBondMarketData(secid: string): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
return request<BondMarketData>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`);
}
export function getBondHistory(
secid: string,
from: string,
till: string,
): Promise<{ data: BondHistoryItem[]; meta: ApiResponseMeta }> {
return request<BondHistoryItem[]>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`, { from, till });
}
export function getShareCandles(
secid: string,
interval: '1h' | '24h',
from: string,
till: string,
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
return request<CandleItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/candles`, {
interval,
from,
till,
});
}
export function getBondCandles(
secid: string,
interval: '1h' | '24h',
from: string,
till: string,
): Promise<{ data: CandleItem[]; meta: ApiResponseMeta }> {
return request<CandleItem[]>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/candles`, {
interval,
from,
till,
});
}

View File

@ -0,0 +1,124 @@
export interface ApiResponseMeta {
cachedAt: string | null;
fromCache: boolean;
}
export interface ApiEnvelope<T> {
data: T;
meta: ApiResponseMeta;
}
export interface StockMarketData {
price: number;
change: number;
changePercent: number;
open: number;
high: number | null;
low: number | null;
volume: number;
value: number;
issueCapitalization: number | null;
updatedAt: string;
}
export interface ShareResponse {
secid: string;
isin: string;
name: string;
shortName: string;
latName: string | null;
listLevel: number;
issueSize: number;
faceValue: number;
faceUnit: string;
type: string;
marketData: StockMarketData;
}
export interface DividendItem {
registryCloseDate: string;
value: number;
currency: string;
}
export interface ShareHistoryItem {
date: string;
open: number;
high: number;
low: number;
close: number;
volume: number;
value: number;
}
export interface BondMarketData {
price: number;
yieldToMaturity: number | null;
duration: number | null;
accruedInt: number;
couponValue: number;
couponPercent: number | null;
nextCouponDate: string | null;
open: number;
high: number | null;
low: number | null;
volume: number;
updatedAt: string;
}
export interface BondResponse {
secid: string;
isin: string;
name: string;
shortName: string;
latName: string | null;
listLevel: number;
issueSize: number;
faceValue: number;
faceUnit: string;
matDate: string;
couponValue: number;
couponPercent: number | null;
couponPeriod: number;
nextCoupon: string | null;
accruedInt: number;
bondType: string;
bondSubType: string;
offerDate: string | null;
buybackDate: string | null;
marketData: BondMarketData;
}
export interface BondHistoryItem {
date: string;
closePrice: number;
yieldClose: number | null;
duration: number | null;
}
export interface CandleItem {
open: number;
high: number;
low: number;
close: number;
volume: number;
value: number;
begin: string;
end: string;
}
export interface SearchResultItem {
secid: string;
isin: string;
shortName: string;
type: 'share' | 'bond';
listLevel: number;
currency: string | null;
price: number | null;
}
export interface HealthResponse {
status: string;
timestamp: string;
uptime: number;
}

View File

@ -1,3 +1,27 @@
import { Outlet, Link } from 'react-router-dom';
import { SearchBar } from './SearchBar';
export function Layout() { export function Layout() {
return <div />; return (
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column' }}>
<header
style={{
background: 'var(--color-surface)',
borderBottom: '1px solid #e0e0e0',
padding: '12px 24px',
display: 'flex',
alignItems: 'center',
gap: 24,
}}
>
<Link to="/" style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', textDecoration: 'none' }}>
MoexVibe
</Link>
<SearchBar />
</header>
<main style={{ flex: 1, padding: 24, maxWidth: 1200, width: '100%', margin: '0 auto' }}>
<Outlet />
</main>
</div>
);
} }

View File

@ -0,0 +1,101 @@
import { useState, useRef, useEffect } from 'react';
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

@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { searchSecurities } from '../api/client';
import type { SearchResultItem } from '../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,3 +1,41 @@
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getBond } from '../api/client';
export function BondPage() { export function BondPage() {
return <div />; const { secid } = useParams<{ secid: string }>();
const { data, isLoading, error } = useQuery({
queryKey: ['bonds', secid],
queryFn: () => getBond(secid!),
enabled: !!secid,
});
if (isLoading) return <div>Загрузка...</div>;
if (error || !data) return <div>Ошибка загрузки данных</div>;
return (
<div>
<h1>{data.data.shortName}</h1>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 16 }}>
{data.data.secid} &middot; {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>
</div>
);
} }

View File

@ -1,3 +1,18 @@
export function HomePage() { export function HomePage() {
return <div />; 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

@ -1,3 +1,47 @@
import { useParams } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getShare } from '../api/client';
export function StockPage() { export function StockPage() {
return <div />; const { secid } = useParams<{ secid: string }>();
const { data, isLoading, error } = useQuery({
queryKey: ['shares', secid],
queryFn: () => getShare(secid!),
enabled: !!secid,
});
if (isLoading) return <div>Загрузка...</div>;
if (error || !data) return <div>Ошибка загрузки данных</div>;
return (
<div>
<h1>{data.data.shortName}</h1>
<p style={{ color: 'var(--color-text-secondary)', marginBottom: 16 }}>
{data.data.secid} &middot; {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>
</div>
</div>
);
} }