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
24 changed files with 584 additions and 505 deletions
Showing only changes of commit b79c8210dc - Show all commits

View File

@ -1,10 +1,4 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
} from '@nestjs/common';
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { Response } from 'express';
@Catch()

View File

@ -1,21 +1,11 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ApiResponse } from '../dto/api-response.dto';
@Injectable()
export class TransformInterceptor<T>
implements NestInterceptor<T, ApiResponse<T>>
{
intercept(
context: ExecutionContext,
next: CallHandler,
): Observable<ApiResponse<T>> {
export class TransformInterceptor<T> implements NestInterceptor<T, ApiResponse<T>> {
intercept(context: ExecutionContext, next: CallHandler): Observable<ApiResponse<T>> {
return next.handle().pipe(
map((data) => {
if (data instanceof ApiResponse) return data;

View File

@ -5,10 +5,7 @@ export default registerAs('app', () => ({
moex: {
baseUrl: process.env.MOEX_BASE_URL || 'https://iss.moex.com/iss',
rateLimit: parseInt(process.env.MOEX_RATE_LIMIT || '10', 10),
circuitBreakerThreshold: parseInt(
process.env.MOEX_CIRCUIT_BREAKER_THRESHOLD || '5',
10,
),
circuitBreakerThreshold: parseInt(process.env.MOEX_CIRCUIT_BREAKER_THRESHOLD || '5', 10),
circuitBreakerResetSeconds: parseInt(
process.env.MOEX_CIRCUIT_BREAKER_RESET_SECONDS || '30',
10,

View File

@ -20,10 +20,7 @@ async function bootstrap() {
app.enableCors();
const config = new DocumentBuilder()
.setTitle('MoexVibe API')
.setVersion('1.0.0')
.build();
const config = new DocumentBuilder().setTitle('MoexVibe API').setVersion('1.0.0').build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/docs', app, document);

View File

@ -16,7 +16,11 @@ describe('BondsService', () => {
MoexClientService,
{
provide: 'CACHE_MANAGER',
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() },
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
],

View File

@ -10,7 +10,11 @@ export class BondsService {
) {}
async getBond(secid: string) {
const { data: bond, fromCache, cachedAt } = await this.cache.getOrFetch(
const {
data: bond,
fromCache,
cachedAt,
} = await this.cache.getOrFetch(
'bond',
[secid],
() => this.moexClient.getBondData(secid),
@ -71,7 +75,11 @@ export class BondsService {
}
async getMarketData(secid: string) {
const { data: mkt, fromCache, cachedAt } = await this.cache.getOrFetch(
const {
data: mkt,
fromCache,
cachedAt,
} = await this.cache.getOrFetch(
'marketdata',
['bonds', secid],
() => this.moexClient.getBondMarketData(secid),

View File

@ -17,7 +17,11 @@ describe('CandlesService', () => {
MoexClientService,
{
provide: 'CACHE_MANAGER',
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() },
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
],

View File

@ -25,8 +25,7 @@ export class CandlesService {
const { data, fromCache, cachedAt } = await this.cache.getOrFetch(
'candles',
[market, secid, String(moexInterval), from, till],
() =>
this.moexClient.getCandles('stock', market, secid, moexInterval, from, till),
() => this.moexClient.getCandles('stock', market, secid, moexInterval, from, till),
'candlesTtl',
);

View File

@ -9,9 +9,7 @@ describe('MoexClientService', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({ load: [configuration] }),
],
imports: [ConfigModule.forRoot({ load: [configuration] })],
providers: [MoexClientService],
}).compile();

View File

@ -25,19 +25,9 @@ export class MoexClientService {
constructor(private configService: ConfigService) {
const baseUrl = this.configService.get<string>('app.moex.baseUrl')!;
this.threshold = this.configService.get<number>(
'app.moex.circuitBreakerThreshold',
5,
);
this.resetMs =
this.configService.get<number>(
'app.moex.circuitBreakerResetSeconds',
30,
) * 1000;
const rateLimit = this.configService.get<number>(
'app.moex.rateLimit',
10,
);
this.threshold = this.configService.get<number>('app.moex.circuitBreakerThreshold', 5);
this.resetMs = this.configService.get<number>('app.moex.circuitBreakerResetSeconds', 30) * 1000;
const rateLimit = this.configService.get<number>('app.moex.rateLimit', 10);
this.client = axios.create({
baseURL: baseUrl,
@ -163,7 +153,9 @@ export class MoexClientService {
open: mkt ? parseFloat((mkt.OPEN as string) || '') : null,
low: mkt ? parseFloat((mkt.LOW as string) || '') : null,
high: mkt ? parseFloat((mkt.HIGH as string) || '') : null,
last: mkt ? parseFloat((mkt.LAST as string) || '') : parseFloat((share.PREVPRICE as string) || ''),
last: mkt
? parseFloat((mkt.LAST as string) || '')
: parseFloat((share.PREVPRICE as string) || ''),
lastChange: mkt ? parseFloat((mkt.LASTCHANGE as string) || '') : null,
lastChangePrcnt: mkt ? parseFloat((mkt.LASTCHANGEPRCNT as string) || '') : null,
volume: mkt ? parseInt((mkt.VOLTODAY as string) || '0', 10) : 0,
@ -278,11 +270,7 @@ export class MoexClientService {
}));
}
async getHistory(
secid: string,
from: string,
till: string,
): Promise<MoexHistoryEntry[]> {
async getHistory(secid: string, from: string, till: string): Promise<MoexHistoryEntry[]> {
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/shares/securities/${secid}`,
{ from, till },
@ -304,11 +292,7 @@ export class MoexClientService {
}));
}
async getBondHistory(
secid: string,
from: string,
till: string,
): Promise<MoexBondHistoryEntry[]> {
async getBondHistory(secid: string, from: string, till: string): Promise<MoexBondHistoryEntry[]> {
const data = await this.request<Record<string, unknown>>(
`/engines/stock/markets/bonds/securities/${secid}`,
{ from, till },
@ -320,8 +304,7 @@ export class MoexClientService {
return this.extractTable(data, tableName).map((h) => ({
tradeDate: h.TRADEDATE as string,
close: h.CLOSE != null ? parseFloat(h.CLOSE as string) : null,
legalClosePrice:
h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
legalClosePrice: h.LEGALCLOSEPRICE != null ? parseFloat(h.LEGALCLOSEPRICE as string) : null,
waprice: h.WAPRICE != null ? parseFloat(h.WAPRICE as string) : null,
yieldClose: h.YIELDCLOSE != null ? parseFloat(h.YIELDCLOSE as string) : null,
duration: h.DURATION != null ? parseFloat(h.DURATION as string) : null,

View File

@ -8,7 +8,15 @@ describe('SecuritiesController', () => {
let service: SecuritiesService;
const mockResults = [
{ secid: 'SBER', isin: 'RU0009029540', shortName: 'Сбербанк', type: 'share', listLevel: 1, currency: 'RUB', price: null },
{
secid: 'SBER',
isin: 'RU0009029540',
shortName: 'Сбербанк',
type: 'share',
listLevel: 1,
currency: 'RUB',
price: null,
},
];
const mockService = {
@ -18,9 +26,7 @@ describe('SecuritiesController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [SecuritiesController],
providers: [
{ provide: SecuritiesService, useValue: mockService },
],
providers: [{ provide: SecuritiesService, useValue: mockService }],
}).compile();
controller = module.get<SecuritiesController>(SecuritiesController);

View File

@ -17,7 +17,11 @@ describe('SecuritiesService', () => {
MoexClientService,
{
provide: 'CACHE_MANAGER',
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() },
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
],

View File

@ -28,9 +28,14 @@ export class SecuritiesService {
const results = await this.moexClient.searchSecurities(query);
return results
.map((s): SearchResultItem | null => {
const type = (s.group === 'stock_shares' || s.type === 'common_share' || s.type === 'preferred_share')
? 'share' as const
: (s.group === 'stock_bonds' ? 'bond' as const : null);
const type =
s.group === 'stock_shares' ||
s.type === 'common_share' ||
s.type === 'preferred_share'
? ('share' as const)
: s.group === 'stock_bonds'
? ('bond' as const)
: null;
if (!type) return null;
return {
secid: s.secid,

View File

@ -16,7 +16,11 @@ describe('SharesService', () => {
MoexClientService,
{
provide: 'CACHE_MANAGER',
useValue: { get: () => undefined, set: () => Promise.resolve(), del: () => Promise.resolve() },
useValue: {
get: () => undefined,
set: () => Promise.resolve(),
del: () => Promise.resolve(),
},
},
CacheService,
],

View File

@ -11,7 +11,14 @@ export class SharesService {
async getShare(secid: string) {
const desc = await this.moexClient.getSecurityDescription(secid);
if (!desc || !(desc.group === 'stock_shares' || desc.type === 'common_share' || desc.type === 'preferred_share')) {
if (
!desc ||
!(
desc.group === 'stock_shares' ||
desc.type === 'common_share' ||
desc.type === 'preferred_share'
)
) {
throw new NotFoundException(`Share ${secid} not found`);
}
@ -55,7 +62,11 @@ export class SharesService {
}
async getMarketData(secid: string) {
const { data: marketData, fromCache, cachedAt } = await this.cache.getOrFetch(
const {
data: marketData,
fromCache,
cachedAt,
} = await this.cache.getOrFetch(
'marketdata',
['shares', secid],
() => this.moexClient.getShareMarketData(secid),

View File

@ -15,7 +15,10 @@ import type {
const BASE = '';
async function request<T>(path: string, params?: Record<string, string>): Promise<{ data: T; meta: ApiResponseMeta }> {
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)) {
@ -37,19 +40,31 @@ export function searchSecurities(
type: 'all' | 'share' | 'bond' = 'all',
limit = 20,
): Promise<{ data: SearchResultItem[]; meta: ApiResponseMeta }> {
return request<SearchResultItem[]>('/api/v1/securities/search', { q, type, limit: String(limit) });
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 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 getShareDividends(
secid: string,
): Promise<{ data: DividendItem[]; meta: ApiResponseMeta }> {
return request<DividendItem[]>(
`/api/v1/securities/shares/${encodeURIComponent(secid)}/dividends`,
);
}
export function getShareHistory(
@ -57,15 +72,22 @@ export function getShareHistory(
from: string,
till: string,
): Promise<{ data: ShareHistoryItem[]; meta: ApiResponseMeta }> {
return request<ShareHistoryItem[]>(`/api/v1/securities/shares/${encodeURIComponent(secid)}/history`, { from, till });
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 getBondMarketData(
secid: string,
): Promise<{ data: BondMarketData; meta: ApiResponseMeta }> {
return request<BondMarketData>(
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/marketdata`,
);
}
export function getBondHistory(
@ -73,7 +95,10 @@ export function getBondHistory(
from: string,
till: string,
): Promise<{ data: BondHistoryItem[]; meta: ApiResponseMeta }> {
return request<BondHistoryItem[]>(`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`, { from, till });
return request<BondHistoryItem[]>(
`/api/v1/securities/bonds/${encodeURIComponent(secid)}/history`,
{ from, till },
);
}
export function getShareCandles(

View File

@ -4,7 +4,7 @@
*/
export interface paths {
"/api/v1/health": {
'/api/v1/health': {
parameters: {
query?: never;
header?: never;
@ -12,7 +12,7 @@ export interface paths {
cookie?: never;
};
/** Проверка состояния сервиса */
get: operations["HealthController_check"];
get: operations['HealthController_check'];
put?: never;
post?: never;
delete?: never;
@ -21,7 +21,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/securities/search": {
'/api/v1/securities/search': {
parameters: {
query?: never;
header?: never;
@ -29,7 +29,7 @@ export interface paths {
cookie?: never;
};
/** Поиск по инструментам */
get: operations["SecuritiesController_search"];
get: operations['SecuritiesController_search'];
put?: never;
post?: never;
delete?: never;
@ -38,7 +38,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/securities/shares/{secid}": {
'/api/v1/securities/shares/{secid}': {
parameters: {
query?: never;
header?: never;
@ -46,7 +46,7 @@ export interface paths {
cookie?: never;
};
/** Получить спецификацию акции */
get: operations["SharesController_getShare"];
get: operations['SharesController_getShare'];
put?: never;
post?: never;
delete?: never;
@ -55,7 +55,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/securities/shares/{secid}/marketdata": {
'/api/v1/securities/shares/{secid}/marketdata': {
parameters: {
query?: never;
header?: never;
@ -63,7 +63,7 @@ export interface paths {
cookie?: never;
};
/** Получить рыночные данные акции */
get: operations["SharesController_getMarketData"];
get: operations['SharesController_getMarketData'];
put?: never;
post?: never;
delete?: never;
@ -72,7 +72,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/securities/shares/{secid}/dividends": {
'/api/v1/securities/shares/{secid}/dividends': {
parameters: {
query?: never;
header?: never;
@ -80,7 +80,7 @@ export interface paths {
cookie?: never;
};
/** Получить дивиденды */
get: operations["SharesController_getDividends"];
get: operations['SharesController_getDividends'];
put?: never;
post?: never;
delete?: never;
@ -89,7 +89,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/securities/shares/{secid}/history": {
'/api/v1/securities/shares/{secid}/history': {
parameters: {
query?: never;
header?: never;
@ -97,7 +97,7 @@ export interface paths {
cookie?: never;
};
/** Получить дневную историю торгов акции */
get: operations["SharesController_getHistory"];
get: operations['SharesController_getHistory'];
put?: never;
post?: never;
delete?: never;
@ -106,7 +106,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/securities/bonds/{secid}": {
'/api/v1/securities/bonds/{secid}': {
parameters: {
query?: never;
header?: never;
@ -114,7 +114,7 @@ export interface paths {
cookie?: never;
};
/** Получить спецификацию облигации */
get: operations["BondsController_getBond"];
get: operations['BondsController_getBond'];
put?: never;
post?: never;
delete?: never;
@ -123,7 +123,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/securities/bonds/{secid}/marketdata": {
'/api/v1/securities/bonds/{secid}/marketdata': {
parameters: {
query?: never;
header?: never;
@ -131,7 +131,7 @@ export interface paths {
cookie?: never;
};
/** Получить рыночные данные облигации */
get: operations["BondsController_getMarketData"];
get: operations['BondsController_getMarketData'];
put?: never;
post?: never;
delete?: never;
@ -140,7 +140,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/securities/bonds/{secid}/history": {
'/api/v1/securities/bonds/{secid}/history': {
parameters: {
query?: never;
header?: never;
@ -148,7 +148,7 @@ export interface paths {
cookie?: never;
};
/** Получить дневную историю торгов облигации */
get: operations["BondsController_getHistory"];
get: operations['BondsController_getHistory'];
put?: never;
post?: never;
delete?: never;
@ -157,7 +157,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/securities/shares/{secid}/candles": {
'/api/v1/securities/shares/{secid}/candles': {
parameters: {
query?: never;
header?: never;
@ -165,7 +165,7 @@ export interface paths {
cookie?: never;
};
/** Получить свечи акции */
get: operations["CandlesController_getShareCandles"];
get: operations['CandlesController_getShareCandles'];
put?: never;
post?: never;
delete?: never;
@ -174,7 +174,7 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/securities/bonds/{secid}/candles": {
'/api/v1/securities/bonds/{secid}/candles': {
parameters: {
query?: never;
header?: never;
@ -182,7 +182,7 @@ export interface paths {
cookie?: never;
};
/** Получить свечи облигации */
get: operations["CandlesController_getBondCandles"];
get: operations['CandlesController_getBondCandles'];
put?: never;
post?: never;
delete?: never;
@ -225,7 +225,7 @@ export interface operations {
query: {
/** @description Поисковый запрос (тикер, название, ISIN) */
q: string;
type?: "all" | "share" | "bond";
type?: 'all' | 'share' | 'bond';
limit?: number;
};
header?: never;
@ -384,7 +384,7 @@ export interface operations {
CandlesController_getShareCandles: {
parameters: {
query: {
interval: "1h" | "24h";
interval: '1h' | '24h';
from: string;
till: string;
};
@ -407,7 +407,7 @@ export interface operations {
CandlesController_getBondCandles: {
parameters: {
query: {
interval: "1h" | "24h";
interval: '1h' | '24h';
from: string;
till: string;
};

View File

@ -15,22 +15,27 @@ 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={{
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 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={{ 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>
<span>
{bond.faceValue.toLocaleString('ru-RU')} {bond.faceUnit}
</span>
</div>
<div style={rowStyle}>
<span>Дата погашения</span>
@ -38,7 +43,9 @@ export function BondDetails({ bond }: BondDetailsProps) {
</div>
<div style={rowStyle}>
<span>Купон</span>
<span>{md.couponValue} {md.couponPercent != null ? `(${md.couponPercent}%)` : ''}</span>
<span>
{md.couponValue} {md.couponPercent != null ? `(${md.couponPercent}%)` : ''}
</span>
</div>
<div style={rowStyle}>
<span>Период купона</span>

View File

@ -14,7 +14,15 @@ export function Layout() {
gap: 24,
}}
>
<Link to="/" style={{ fontSize: 20, fontWeight: 700, color: 'var(--color-text)', textDecoration: 'none' }}>
<Link
to="/"
style={{
fontSize: 20,
fontWeight: 700,
color: 'var(--color-text)',
textDecoration: 'none',
}}
>
MoexVibe
</Link>
<SearchBar />

View File

@ -32,7 +32,10 @@ export function SearchBar() {
type="text"
placeholder="Поиск акций и облигаций..."
value={query}
onChange={e => { setQuery(e.target.value); setOpen(true); }}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
style={{
width: '100%',
@ -66,13 +69,15 @@ export function SearchBar() {
<li style={{ padding: 12, color: '#888' }}>Ничего не найдено</li>
)}
{!isLoading &&
results?.map(item => (
results?.map((item) => (
<li
key={item.secid}
onClick={() => {
setOpen(false);
setQuery('');
navigate(item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`);
navigate(
item.type === 'share' ? `/stocks/${item.secid}` : `/bonds/${item.secid}`,
);
}}
style={{
padding: '10px 12px',
@ -82,14 +87,16 @@ export function SearchBar() {
justifyContent: 'space-between',
alignItems: 'center',
}}
onMouseEnter={e => (e.currentTarget.style.background = '#f5f5f5')}
onMouseLeave={e => (e.currentTarget.style.background = '')}
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' }}>
<span
style={{ fontSize: 12, color: item.type === 'share' ? '#1976d2' : '#2e7d32' }}
>
{item.type === 'share' ? 'Акция' : 'Облигация'}
</span>
</li>

View File

@ -16,7 +16,14 @@ export function StockDetails({ stock }: StockDetailsProps) {
const isPositive = md.change >= 0;
return (
<div style={{ background: 'var(--color-surface)', borderRadius: 'var(--border-radius)', boxShadow: 'var(--shadow)', padding: 24 }}>
<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})
@ -28,8 +35,14 @@ export function StockDetails({ stock }: StockDetailsProps) {
<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
style={{
fontSize: 18,
color: isPositive ? 'var(--color-positive)' : 'var(--color-negative)',
}}
>
{isPositive ? '+' : ''}
{md.change.toFixed(2)} ({md.changePercent.toFixed(2)}%)
</span>
</div>
@ -53,9 +66,7 @@ export function StockDetails({ stock }: StockDetailsProps) {
<div style={rowStyle}>
<span>Капитализация</span>
<span>
{md.issueCapitalization
? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽'
: '—'}
{md.issueCapitalization ? (md.issueCapitalization / 1e9).toFixed(2) + ' млрд ₽' : '—'}
</span>
</div>
<div style={rowStyle}>

View File

@ -18,7 +18,14 @@ export function BondPage() {
<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 }}>
<div
style={{
background: 'var(--color-surface)',
borderRadius: 'var(--border-radius)',
boxShadow: 'var(--shadow)',
padding: 24,
}}
>
<h3 style={{ marginBottom: 16 }}>График цены</h3>
<PriceChart data={candles ?? []} />
</div>

View File

@ -1,15 +1,11 @@
export function HomePage() {
return (
<div style={{ textAlign: 'center', paddingTop: 120 }}>
<h1 style={{ fontSize: 32, fontWeight: 700, marginBottom: 12 }}>
MoexVibe
</h1>
<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: '#888', fontSize: 13 }}>Введите название или тикер в строку поиска выше</p>
<p style={{ color: '#aaa', fontSize: 12, marginTop: 8 }}>
Данные задерживаются на 15 минут &middot; Бесплатный API MOEX ISS
</p>

View File

@ -20,13 +20,27 @@ export function StockPage() {
<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 }}>
<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 }}>
<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>