MVP реализации MoexVibe — NestJS бэкенд + React фронтенд + CI/CD #1
103
apps/frontend/src/api/client.ts
Normal file
103
apps/frontend/src/api/client.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
124
apps/frontend/src/api/responses.ts
Normal file
124
apps/frontend/src/api/responses.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
101
apps/frontend/src/components/SearchBar.tsx
Normal file
101
apps/frontend/src/components/SearchBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
apps/frontend/src/hooks/useSearch.ts
Normal file
15
apps/frontend/src/hooks/useSearch.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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} · {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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 минут · Бесплатный API MOEX ISS
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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} · {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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user