Рефакторинг конфигурации и структуры проекта

- Обновлен config.py с оптимизированными словарями секторов и индексов
- Удалены устаревшие классы exchange.py и moex_class.py
- Модернизирован moex_history.py с улучшенной логикой получения данных
- Обновлен requirements.txt с современными зависимостями для финансовой платформы
- Упрощен open_router.ipynb с фокусом на экономических темах
This commit is contained in:
belikovme 2025-03-12 17:01:25 +07:00
parent 61b6f03e26
commit 391757d581
26 changed files with 2735 additions and 679 deletions

98
README.md Normal file
View File

@ -0,0 +1,98 @@
# Финансово-аналитическая платформа
Платформа для отображения финансовых данных, новостей и аналитики.
## Структура проекта
Проект состоит из следующих компонентов:
- **Backend**: FastAPI сервер для обработки запросов
- **Frontend**: Next.js приложение для отображения данных
- **Сборщик данных**: Скрипт для сбора и обновления данных
## Требования
- Python 3.8+
- Node.js 16+
- npm или yarn
## Установка и запуск
### Backend
1. Установите зависимости Python:
```bash
pip install -r requirements.txt
```
2. Запустите сборщик данных для первоначального сбора:
```bash
python data_collector.py --init
```
3. Запустите FastAPI сервер:
```bash
uvicorn api:app --reload
```
Сервер будет доступен по адресу: http://localhost:8000
### Frontend
1. Перейдите в директорию frontend:
```bash
cd frontend
```
2. Установите зависимости:
```bash
npm install
# или
yarn install
```
3. Запустите Next.js приложение:
```bash
npm run dev
# или
yarn dev
```
Приложение будет доступно по адресу: http://localhost:3000
## Запуск планировщика задач
Для регулярного обновления данных запустите планировщик задач:
```bash
python data_collector.py --schedule
```
Планировщик будет выполнять следующие задачи:
- Сбор финансовых данных каждые 12 часов
- Сбор новостей каждые 2 часа
- Анализ новостей каждые 24 часа в полночь
## API Endpoints
- `/sectors` - Получение списка всех доступных секторов
- `/sector/{sector_name}` - Получение данных по сектору
- `/tickers` - Получение списка всех доступных тикеров
- `/ticker/{ticker_name}` - Получение данных по тикеру
- `/news/topics` - Получение списка всех доступных тем новостей
- `/news` - Получение новостей
- `/analytics/latest` - Получение последнего доступного анализа новостей
- `/analytics/{date}` - Получение анализа новостей по дате
- `/dashboard` - Получение сводных данных для главного экрана
## Структура базы данных
- **moex_data.db** - База данных с финансовыми данными
- **news_data.db** - База данных с новостями
- **analytics.db** - База данных с результатами анализа новостей

236
api.py Normal file
View File

@ -0,0 +1,236 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from fastapi import FastAPI, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import List, Dict, Any, Optional
from datetime import datetime, timedelta
import logging
from models import FinancialDataManager, NewsManager, AnalyticsManager
from config import API_CONFIG
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("api.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger("api")
# Создаем экземпляр FastAPI
app = FastAPI(
title="Финансово-аналитическая платформа API",
description="API для доступа к финансовым данным, новостям и аналитике",
version="1.0.0"
)
# Настройка CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # В продакшене следует указать конкретные домены
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Инициализация менеджеров данных
financial_manager = FinancialDataManager()
news_manager = NewsManager()
analytics_manager = AnalyticsManager()
# Модели данных для API
class SectorData(BaseModel):
sector: str
date: str
open: float
high: float
low: float
close: float
volume: float
class TickerData(BaseModel):
ticker: str
sector: str
date: str
open: float
high: float
low: float
close: float
volume: float
class NewsItem(BaseModel):
id: int
date: str
topic: Optional[str] = None
title: str
content: str
url: Optional[str] = None
class AnalyticsItem(BaseModel):
id: int
date: str
summary: str
business_problems: Optional[str] = None
solutions: Optional[str] = None
sentiment: Optional[float] = None
# Маршруты API
@app.get("/")
async def root():
"""Корневой маршрут API"""
return {
"message": "Добро пожаловать в API финансово-аналитической платформы",
"version": "1.0.0",
"endpoints": [
"/sectors",
"/sectors/{sector}",
"/tickers",
"/tickers/{ticker}",
"/news",
"/news/topics",
"/analytics"
]
}
# Маршруты для финансовых данных
@app.get("/sectors", response_model=List[str])
async def get_sectors():
"""Получить список всех доступных секторов"""
try:
sectors = financial_manager.get_all_sectors()
return sectors
except Exception as e:
logger.error(f"Ошибка при получении списка секторов: {str(e)}")
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.get("/sectors/{sector}", response_model=List[Dict[str, Any]])
async def get_sector_data(
sector: str,
start_date: Optional[str] = Query(None, description="Начальная дата в формате YYYY-MM-DD"),
end_date: Optional[str] = Query(None, description="Конечная дата в формате YYYY-MM-DD")
):
"""Получить данные по указанному сектору за период"""
try:
# Если даты не указаны, используем последние 30 дней
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
if not start_date:
start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
data = financial_manager.get_sector_data(sector, start_date, end_date)
if not data:
raise HTTPException(status_code=404, detail=f"Данные для сектора {sector} не найдены")
return data
except HTTPException:
raise
except Exception as e:
logger.error(f"Ошибка при получении данных сектора {sector}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.get("/tickers", response_model=Dict[str, List[str]])
async def get_tickers():
"""Получить список всех доступных тикеров, сгруппированных по секторам"""
try:
tickers = financial_manager.get_all_tickers()
return tickers
except Exception as e:
logger.error(f"Ошибка при получении списка тикеров: {str(e)}")
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.get("/tickers/{ticker}", response_model=List[Dict[str, Any]])
async def get_ticker_data(
ticker: str,
start_date: Optional[str] = Query(None, description="Начальная дата в формате YYYY-MM-DD"),
end_date: Optional[str] = Query(None, description="Конечная дата в формате YYYY-MM-DD")
):
"""Получить данные по указанному тикеру за период"""
try:
# Если даты не указаны, используем последние 30 дней
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
if not start_date:
start_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
data = financial_manager.get_ticker_data(ticker, start_date, end_date)
if not data:
raise HTTPException(status_code=404, detail=f"Данные для тикера {ticker} не найдены")
return data
except HTTPException:
raise
except Exception as e:
logger.error(f"Ошибка при получении данных тикера {ticker}: {str(e)}")
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
# Маршруты для новостей
@app.get("/news", response_model=List[Dict[str, Any]])
async def get_news(
start_date: Optional[str] = Query(None, description="Начальная дата в формате YYYY-MM-DD"),
end_date: Optional[str] = Query(None, description="Конечная дата в формате YYYY-MM-DD"),
topic: Optional[str] = Query(None, description="Фильтр по теме новости")
):
"""Получить новости за указанный период с возможностью фильтрации по теме"""
try:
# Если даты не указаны, используем последние 7 дней
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
if not start_date:
start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
if topic:
news = news_manager.get_news_by_topic(topic, start_date, end_date)
else:
news = news_manager.get_news_by_date(start_date, end_date)
return news
except Exception as e:
logger.error(f"Ошибка при получении новостей: {str(e)}")
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
@app.get("/news/topics", response_model=List[str])
async def get_news_topics():
"""Получить список всех доступных тем новостей"""
try:
topics = news_manager.get_topics()
return topics
except Exception as e:
logger.error(f"Ошибка при получении списка тем новостей: {str(e)}")
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
# Маршруты для аналитики
@app.get("/analytics", response_model=List[Dict[str, Any]])
async def get_analytics(
start_date: Optional[str] = Query(None, description="Начальная дата в формате YYYY-MM-DD"),
end_date: Optional[str] = Query(None, description="Конечная дата в формате YYYY-MM-DD")
):
"""Получить аналитику за указанный период"""
try:
# Если даты не указаны, используем последние 7 дней
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
if not start_date:
start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
analytics = analytics_manager.get_analytics_by_date(start_date, end_date)
return analytics
except Exception as e:
logger.error(f"Ошибка при получении аналитики: {str(e)}")
raise HTTPException(status_code=500, detail=f"Внутренняя ошибка сервера: {str(e)}")
# Запуск сервера
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"api:app",
host=API_CONFIG['host'],
port=API_CONFIG['port'],
reload=True
)

View File

@ -1,138 +0,0 @@
import yfinance as yf
from datetime import datetime, timedelta
import logging
class ForexDataHandler:
def __init__(self):
"""
Инициализация обработчика валютных данных
"""
self.logger = logging.getLogger(__name__)
# logging.basicConfig(level=logging.INFO)
self.logger = logging.getLogger(__name__)
# logging.basicConfig(level=logging.INFO)
# Словари с тикерами
self.russian_indices = {
'IMOEX': 'IMOEX.ME', # Индекс МосБиржи
'RTSI': 'RTSI.ME', # Индекс РТС
'MOEXBC': 'MOEXBC.ME' # Индекс голубых фишек
}
self.russian_sectors = {
'oil_gas': 'MOEXOG.ME', # Нефть и газ
'finance': 'MOEXFN.ME', # Финансы
'telecom': 'MOEXTL.ME', # Телекоммуникации
'metals': 'MOEXMM.ME', # Металлы и добыча
'consumer': 'MOEXCN.ME', # Потребительский сектор
'transport': 'MOEXTN.ME' # Транспорт
}
self.russian_stocks = {
'Gazprom': 'GAZP.ME',
'Sberbank': 'SBER.ME',
'Lukoil': 'LKOH.ME',
'Rosneft': 'ROSN.ME',
'VTB': 'VTBR.ME'
}
def get_forex_rate(self, from_currency: str, to_currency: str) -> dict:
"""
Получает текущий курс валют с Yahoo Finance
Args:
from_currency: исходная валюта (например, 'USD')
to_currency: целевая валюта (например, 'RUB')
Returns:
dict: Словарь с данными о курсе валют
"""
try:
# Формируем тикер для валютной пары
ticker = f"{from_currency}{to_currency}=X"
self.logger.info(f"Получение данных для пары {ticker}")
currency_pair = yf.Ticker(ticker)
# Получаем данные за последний день
end_date = datetime.now()
start_date = end_date - timedelta(days=1)
hist = currency_pair.history(start=start_date, end=end_date)
if hist.empty:
raise ValueError(f"Данные не получены для пары {ticker}")
latest_data = hist.iloc[-1]
return {
'close': float(latest_data['Close']),
'timestamp': latest_data.name.strftime('%Y-%m-%d %H:%M:%S'),
'open': float(latest_data['Open']),
'high': float(latest_data['High']),
'low': float(latest_data['Low']),
'volume': float(latest_data['Volume']) if 'Volume' in latest_data else None
}
except Exception as e:
self.logger.error(f"Ошибка при получении данных для пары {from_currency}/{to_currency}: {e}")
raise
def get_market_data(self, ticker: str, period: str = "1d") -> dict:
"""
Получает данные по указанному тикеру
Args:
ticker: тикер инструмента
period: период данных ("1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max")
"""
try:
stock = yf.Ticker(ticker)
info = stock.info
hist = stock.history(period=period)
if hist.empty:
raise ValueError(f"Данные не получены для {ticker}")
latest_data = hist.iloc[-1]
return {
'close': float(latest_data['Close']),
'open': float(latest_data['Open']),
'high': float(latest_data['High']),
'low': float(latest_data['Low']),
'volume': float(latest_data['Volume']) if 'Volume' in latest_data else None,
'timestamp': latest_data.name.strftime('%Y-%m-%d %H:%M:%S'),
'name': info.get('longName', None),
'sector': info.get('sector', None),
'market_cap': info.get('marketCap', None),
'pe_ratio': info.get('trailingPE', None),
'dividend_yield': info.get('dividendYield', None),
'currency': info.get('currency', None)
}
except Exception as e:
self.logger.error(f"Ошибка при получении данных для {ticker}: {e}")
raise
def get_sector_performance(self, sector: str) -> dict:
"""
Получает данные по определенному сектору
Args:
sector: ключ сектора из словаря russian_sectors
"""
if sector not in self.russian_sectors:
raise ValueError(f"Неизвестный сектор: {sector}")
return self.get_market_data(self.russian_sectors[sector])
def get_index_data(self, index: str) -> dict:
"""
Получает данные по индексу
Args:
index: ключ индекса из словаря russian_indices
"""
if index not in self.russian_indices:
raise ValueError(f"Неизвестный индекс: {index}")
return self.get_market_data(self.russian_indices[index])

View File

@ -1,70 +0,0 @@
import pandas as pd
import requests
from datetime import datetime, timedelta
class RussianMarketData:
def __init__(self):
self.moex_base_url = "https://iss.moex.com/iss"
# Словарь с индексами
self.indices = {
'oil_gas': 'MOEXOG', # Нефть и газ
'finance': 'MOEXFN', # Финансы
'telecom': 'MOEXTL', # Телекоммуникации
'metals': 'MOEXMM', # Металлы и добыча
'consumer': 'MOEXCN', # Потребительский сектор
'transport': 'MOEXTN' # Транспорт
}
def get_moex_index_history(self, index_code='MOEXOG', start_date=None, end_date=None):
"""
Получение исторических данных индекса MOEX.
"""
if start_date is None:
start_date = (datetime.now() - timedelta(days=365)).strftime('%Y-%m-%d')
if end_date is None:
end_date = datetime.now().strftime('%Y-%m-%d')
all_data = []
start = 0
while True:
url = f"{self.moex_base_url}/history/engines/stock/markets/index/securities/{index_code}.json"
params = {
'from': start_date,
'till': end_date,
'start': start
}
response = requests.get(url, params=params)
data = response.json()
# Проверка наличия данных
if 'history' not in data or len(data['history']['data']) == 0:
break
# Добавление данных в общий список
all_data.extend(data['history']['data'])
start += 100 # Увеличиваем смещение на 100 для следующего запроса
# Преобразование данных в DataFrame
columns = data['history']['columns']
df = pd.DataFrame(all_data, columns=columns)
# Обработка данных
df['TRADEDATE'] = pd.to_datetime(df['TRADEDATE'])
df = df.set_index('TRADEDATE')
return df[['CLOSE', 'VOLUME']]
def get_historical_data(self, sector: str, start_date=None, end_date=None):
"""
Получает исторические данные для указанного сектора.
Args:
sector: ключ сектора из словаря indices
"""
if sector not in self.indices:
raise ValueError(f"Неизвестный сектор: {sector}")
index_code = self.indices[sector]
return self.get_moex_index_history(index_code, start_date, end_date)

View File

@ -1,228 +1,141 @@
import pandas as pd
import requests
from datetime import datetime, timedelta
from typing import List, Dict, Optional
import asyncio
import aiohttp
import numpy as np
import asyncio
from datetime import datetime, timedelta
import sys
import os
# Добавляем родительскую директорию в путь для импорта
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from config import sector_indices
class MOEXHistoricalData:
"""Класс для получения исторических данных с Московской биржи"""
def __init__(self):
"""Инициализация класса для работы с историческими данными MOEX"""
self.base_url = "https://iss.moex.com/iss"
# Словарь соответствия секторов и их индексов на MOEX
self.sector_indices = {
'metals_mining': 'MOEXMM', # Индекс Металлов и добычи
'oil_gas': 'MOEXOG', # Индекс Нефти и газа
'chemicals': 'MOEXCH', # Индекс Химии и нефтехимии
'electric_utilities': 'MOEXEU', # Индекс Электроэнергетики
'telecom': 'MOEXTL', # Индекс Телекоммуникаций
'finance': 'MOEXFN', # Индекс Финансов
'consumer': 'MOEXCN', # Индекс Потребительского сектора
'transport': 'MOEXTN' # Индекс Транспорта
}
async def get_security_history(
self,
ticker: str,
start_date: str,
end_date: str,
engine: str = "stock",
market: str = "shares",
board: str = "TQBR"
) -> pd.DataFrame:
async def _make_request(self, url: str) -> dict:
"""
Получение исторических данных по отдельному тикеру
Выполняет асинхронный запрос к API MOEX
Args:
ticker: Тикер акции
start_date: Начальная дата в формате YYYY-MM-DD
end_date: Конечная дата в формате YYYY-MM-DD
engine: Торговый движок (по умолчанию stock)
market: Рынок (по умолчанию shares)
board: Режим торгов (по умолчанию TQBR)
url: URL для запроса
Returns:
DataFrame с историческими данными
dict: Ответ от API в формате JSON
"""
url = f"{self.base_url}/history/engines/{engine}/markets/{market}/boards/{board}/securities/{ticker}.json"
all_data = []
start = 0
async with aiohttp.ClientSession() as session:
while True:
params = {
"from": start_date,
"till": end_date,
"start": start,
"limit": 100
}
async with session.get(url, params=params) as response:
data = await response.json()
# Получаем данные истории
history_data = data['history']
if not history_data['data']:
break
# Добавляем данные в общий список
all_data.extend(history_data['data'])
start += 100
if len(history_data['data']) < 100:
break
# Создаем DataFrame
df = pd.DataFrame(all_data, columns=history_data['columns'])
# Конвертируем даты и числовые значения
df['TRADEDATE'] = pd.to_datetime(df['TRADEDATE'])
numeric_columns = ['OPEN', 'HIGH', 'LOW', 'CLOSE', 'VALUE', 'VOLUME']
df[numeric_columns] = df[numeric_columns].apply(pd.to_numeric)
return df
async def get_sector_history(
self,
sector_tickers: List[str],
start_date: str,
end_date: str,
engine: str = "stock",
market: str = "shares",
board: str = "TQBR"
) -> Dict[str, pd.DataFrame]:
async with session.get(url) as response:
if response.status == 200:
return await response.json()
else:
raise Exception(f"Ошибка при запросе к API MOEX: {response.status}")
async def get_official_sector_index(self, sector: str, start_date: str, end_date: str) -> pd.DataFrame:
"""
Получение исторических данных по всем тикерам сектора
Получает исторические данные по официальному индексу сектора
Args:
sector_tickers: Список тикеров сектора
start_date: Начальная дата в формате YYYY-MM-DD
end_date: Конечная дата в формате YYYY-MM-DD
engine: Торговый движок (по умолчанию stock)
market: Рынок (по умолчанию shares)
board: Режим торгов (по умолчанию TQBR)
sector: Название сектора
start_date: Начальная дата в формате 'YYYY-MM-DD'
end_date: Конечная дата в формате 'YYYY-MM-DD'
Returns:
Словарь {тикер: DataFrame с историческими данными}
pd.DataFrame: Исторические данные по индексу
"""
tasks = []
for ticker in sector_tickers:
task = self.get_security_history(
ticker=ticker,
start_date=start_date,
end_date=end_date,
engine=engine,
market=market,
board=board
)
tasks.append(task)
# Получаем код индекса для сектора
index_code = sector_indices.get(sector)
if not index_code:
raise ValueError(f"Неизвестный сектор: {sector}")
# Формируем URL для запроса
url = f"{self.base_url}/history/engines/stock/markets/index/securities/{index_code}/candles.json"
url += f"?from={start_date}&till={end_date}&interval=24"
# Выполняем запрос
response = await self._make_request(url)
# Преобразуем ответ в DataFrame
if 'candles' in response and 'data' in response['candles']:
df = pd.DataFrame(response['candles']['data'], columns=response['candles']['columns'])
results = await asyncio.gather(*tasks)
return dict(zip(sector_tickers, results))
async def get_official_sector_index(
self,
sector: str,
start_date: str,
end_date: str
) -> pd.DataFrame:
# Переименовываем колонки для соответствия формату
df = df.rename(columns={
'begin': 'TRADEDATE',
'open': 'OPEN',
'high': 'HIGH',
'low': 'LOW',
'close': 'CLOSE',
'volume': 'VOLUME'
})
# Преобразуем дату в формат datetime
df['TRADEDATE'] = pd.to_datetime(df['TRADEDATE']).dt.date
return df
else:
return pd.DataFrame()
async def get_security_history(self, ticker: str, start_date: str, end_date: str) -> pd.DataFrame:
"""
Получение официального отраслевого индекса с MOEX
Получает исторические данные по ценной бумаге
Args:
sector: Название сектора (ключ из словаря sector_indices)
start_date: Начальная дата в формате YYYY-MM-DD
end_date: Конечная дата в формате YYYY-MM-DD
ticker: Тикер ценной бумаги
start_date: Начальная дата в формате 'YYYY-MM-DD'
end_date: Конечная дата в формате 'YYYY-MM-DD'
Returns:
DataFrame с данными индекса
pd.DataFrame: Исторические данные по ценной бумаге
"""
if sector not in self.sector_indices:
raise ValueError(f"Неизвестный сектор: {sector}. Доступные секторы: {list(self.sector_indices.keys())}")
# Формируем URL для запроса
url = f"{self.base_url}/history/engines/stock/markets/shares/securities/{ticker}/candles.json"
url += f"?from={start_date}&till={end_date}&interval=24"
# Выполняем запрос
response = await self._make_request(url)
# Преобразуем ответ в DataFrame
if 'candles' in response and 'data' in response['candles']:
df = pd.DataFrame(response['candles']['data'], columns=response['candles']['columns'])
index_ticker = self.sector_indices[sector]
url = f"{self.base_url}/history/engines/stock/markets/index/securities/{index_ticker}.json"
all_data = []
start = 0
async with aiohttp.ClientSession() as session:
while True:
params = {
"from": start_date,
"till": end_date,
"start": start,
"limit": 100
}
async with session.get(url, params=params) as response:
data = await response.json()
history_data = data['history']
if not history_data['data']:
break
all_data.extend(history_data['data'])
start += 100
if len(history_data['data']) < 100:
break
df = pd.DataFrame(all_data, columns=history_data['columns'])
# Конвертируем даты и числовые значения
df['TRADEDATE'] = pd.to_datetime(df['TRADEDATE'])
numeric_columns = ['OPEN', 'HIGH', 'LOW', 'CLOSE', 'VALUE', 'VOLUME']
df[numeric_columns] = df[numeric_columns].apply(pd.to_numeric)
return df
# Переименовываем колонки для соответствия формату
df = df.rename(columns={
'begin': 'TRADEDATE',
'open': 'OPEN',
'high': 'HIGH',
'low': 'LOW',
'close': 'CLOSE',
'volume': 'VOLUME'
})
# Преобразуем дату в формат datetime
df['TRADEDATE'] = pd.to_datetime(df['TRADEDATE']).dt.date
return df
else:
return pd.DataFrame()
def calculate_sector_index(
self,
sector_data: Dict[str, pd.DataFrame],
weights: Optional[Dict[str, float]] = None
) -> pd.DataFrame:
"""
Расчет индекса сектора на основе исторических данных входящих в него компаний
# Пример использования
if __name__ == "__main__":
async def test():
moex = MOEXHistoricalData()
Args:
sector_data: Словарь с историческими данными по тикерам {тикер: DataFrame}
weights: Словарь с весами компаний {тикер: вес}. Если None, веса будут равными
Returns:
DataFrame с рассчитанным индексом сектора
"""
# Если веса не указаны, используем равные веса
if weights is None:
weights = {ticker: 1/len(sector_data) for ticker in sector_data.keys()}
# Создаем DataFrame с датами и ценами закрытия для каждого тикера
prices_df = pd.DataFrame()
# Получаем данные по индексу нефти и газа за последний месяц
end_date = datetime.now()
start_date = end_date - timedelta(days=30)
for ticker, df in sector_data.items():
prices_df[ticker] = df.set_index('TRADEDATE')['CLOSE']
# Рассчитываем относительное изменение цен
returns_df = prices_df.pct_change()
df = await moex.get_official_sector_index(
'oil_gas',
start_date.strftime('%Y-%m-%d'),
end_date.strftime('%Y-%m-%d')
)
# Рассчитываем взвешенную доходность индекса
weighted_returns = pd.DataFrame()
for ticker in returns_df.columns:
weighted_returns[ticker] = returns_df[ticker] * weights[ticker]
index_returns = weighted_returns.sum(axis=1)
# Рассчитываем значения индекса
index_values = (1 + index_returns).cumprod() * 1000 # Начальное значение 1000
# Создаем итоговый DataFrame
result_df = pd.DataFrame({
'INDEX_VALUE': index_values,
'INDEX_RETURN': index_returns
})
return result_df
print(df.head())
asyncio.run(test())

307
config.py
View File

@ -1,255 +1,54 @@
sector_indices = {
'metals_mining': 'MOEXMM', # Индекс Металлов и добычи
'oil_gas': 'MOEXOG', # Индекс Нефти и газа
'chemicals': 'MOEXCH', # Индекс Химии и нефтехимии
'electric_utilities': 'MOEXEU', # Индекс Электроэнергетики
'telecom': 'MOEXTL', # Индекс Телекоммуникаций
'finance': 'MOEXFN', # Индекс Финансов
'consumer': 'MOEXCN', # Индекс Потребительского сектора
'transport': 'MOEXTN' # Индекс Транспорта
}
# Конфигурационный файл для финансово-аналитической платформы
sector_tickers = {'metals_mining': ['ALRS',
'AMEZ',
'BELO',
'BLNG',
'CHEP',
'CHMF',
'CHMK',
'CHZN',
'ENPG',
'GMKN',
'KBTK',
'KOGK',
'LNZL',
'LNZLP',
'MAGN',
'MGOK',
'MTLR',
'MTLRP',
'NLMK',
'PGIL',
'PLZL',
'PMTL',
'POGR',
'POLY',
'RASP',
'RUAL',
'RUALR',
'SELG',
'SELGP',
'TRMK',
'UGLD',
'UNKL',
'VSMO',
'VSMZ'],
'oil_gas': ['BANE',
'BANEP',
'GAZP',
'JNOSP',
'KRKNP',
'LKOH',
'MFGS',
'MFGSP',
'NOTK',
'NVTK',
'RITK',
'RNFT',
'RNHSP',
'ROSN',
'SIBN',
'SNGS',
'SNGSP',
'TATN',
'TATNP',
'TNBP',
'TNBPP',
'TRMK',
'TRNFP'],
'chemicals': ['AKRN',
'AZKM',
'DGBZ',
'DGBZP',
'KAZT',
'KZOS',
'KZOSP',
'MGNZ',
'NKNC',
'NKNCP',
'OMSH',
'PHOR',
'SILV',
'URKA',
'YASH'],
'electric_utilities': ['ARSB',
'BEGY',
'DVEC',
'EESR',
'EESRP',
'ELFV',
'ENRU',
'EONR',
'FEES',
'HYDR',
'IRAO',
'IRGZ',
'KISB',
'KRNG',
'KRSG',
'LSNG',
'LSNGP',
'MGSV',
'MRKC',
'MRKH',
'MRKK',
'MRKP',
'MRKS',
'MRKU',
'MRKV',
'MRKY',
'MRKZ',
'MSNG',
'MSRS',
'MSSB',
'MSSV',
'OGK1',
'OGK2',
'OGK4',
'OGK6',
'OGKA',
'OGKB',
'OGKC',
'OGKD',
'OGKE',
'OGKF',
'RSTI',
'RSTIP',
'SAGO',
'SARE',
'SVER',
'TGKA',
'TGKB',
'TGKD',
'TGKE',
'TGKF',
'TGKH',
'TGKI',
'TGKJ',
'TGKN',
'TNSE',
'UPRO',
'VRAO',
'VTGK',
'YKEN'],
'telecom': ['AFKC',
'AFKS',
'BISV',
'BISVP',
'CMST',
'CNTL',
'CNTLP',
'CTLK',
'DLSV',
'DLSVP',
'MFON',
'MGTS',
'MGTSP',
'MTSI',
'MTSS',
'RTKM',
'RTKMP',
'SPTL',
'SPTLP',
'STKM',
'STKMP',
'TTLK',
'URSI',
'URSIP',
'UTEL',
'VTEL',
'VTELP'],
'finance': ['AFKS',
'BSPB',
'BSPBP',
'CBOM',
'EPLN',
'FTRE',
'LEAS',
'MBNK',
'MMBM',
'MOEX',
'PSBR',
'QIWI',
'RENI',
'ROSB',
'SBER',
'SBERP',
'SFIN',
'SPBE',
'T',
'TAVR',
'TCSG',
'TRHN',
'URSAP',
'VTBR',
'VTBS',
'VZRZ',
'VZRZP',
'YRSL',
'ZAYM'],
'consumer': ['ABIO',
'AGRO',
'APTK',
'AQUA',
'AVAZ',
'AVAZP',
'BELU',
'DELI',
'DIXY',
'DSKY',
'EUTR',
'FIVE',
'FIXP',
'GCHE',
'GEMC',
'GRAZ',
'HNFG',
'ISKJ',
'KLNA',
'LENT',
'LIFE',
'LNTA',
'MDMG',
'MGNT',
'MVID',
'OBUV',
'OKEY',
'ORUP',
'OTCP',
'PHST',
'PKBA',
'PKBAP',
'PRMD',
'PRTK',
'ROST',
'RSEA',
'SCOH',
'SCON',
'SVAV',
'SYNG',
'VFRM',
'VRPH',
'VSEH',
'WBDF',
'WUSH',
'YNDX'],
'transport': ['AFLT',
'FESH',
'FLOT',
'GLTR',
'GTRK',
'NKHP',
'NMTP',
'TAER',
'TRCN',
'UTAR']}
# Словарь соответствия секторов и их официальных индексов на MOEX
sector_indices = {
'oil_gas': 'MOEXOG', # Нефть и газ
'electric': 'MOEXEU', # Электроэнергетика
'telecom': 'MOEXTL', # Телекоммуникации
'metals': 'MOEXMM', # Металлы и добыча
'finance': 'MOEXFN', # Финансы
'consumer': 'MOEXCN', # Потребительский сектор
'chemicals': 'MOEXCH', # Химия и нефтехимия
'transport': 'MOEXTN', # Транспорт
'it': 'MOEXIT' # Информационные технологии
}
# Словарь соответствия секторов и тикеров компаний, входящих в них
sector_tickers = {
'oil_gas': ['GAZP', 'LKOH', 'ROSN', 'SNGS', 'TATN', 'NVTK'],
'electric': ['IRAO', 'MSNG', 'RSTI', 'HYDR', 'FEES', 'UPRO'],
'telecom': ['MTSS', 'RTKM', 'MGTS', 'MFON'],
'metals': ['GMKN', 'NLMK', 'MAGN', 'CHMF', 'PLZL', 'POLY', 'ALRS'],
'finance': ['SBER', 'VTBR', 'CBOM', 'MOEX', 'TCSG'],
'consumer': ['MGNT', 'FIVE', 'DSKY', 'FIXP', 'OZON'],
'chemicals': ['PHOR', 'AKRN', 'NKNC', 'KAZT'],
'transport': ['AFLT', 'RTKM', 'NMTP'],
'it': ['YNDX', 'QIWI', 'CIAN', 'VKCO']
}
# Настройки базы данных
DB_CONFIG = {
'financial_data': 'moex_data.db',
'news_data': 'news_data.db',
'analytics': 'analytics.db'
}
# Настройки сбора данных
DATA_COLLECTION = {
'financial_interval_hours': 12,
'news_interval_hours': 2,
'analytics_interval_hours': 24
}
# Настройки API
API_CONFIG = {
'host': '0.0.0.0',
'port': 8000
}
# Настройки для OpenAI API (для анализа новостей)
OPENAI_CONFIG = {
'model': 'gpt-3.5-turbo',
'temperature': 0.3,
'max_tokens': 500
}

0
data_collector.log Normal file
View File

356
data_collector.py Normal file
View File

@ -0,0 +1,356 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import argparse
import asyncio
import sqlite3
import os
import schedule
import time
from datetime import datetime, timedelta
import pandas as pd
import logging
from models import FinancialDataManager, NewsManager, AnalyticsManager
from classes.moex_history import MOEXHistoricalData
from news_parser import NewsParser
from config import sector_tickers, sector_indices, DB_CONFIG, DATA_COLLECTION
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler("data_collector.log"),
logging.StreamHandler()
]
)
logger = logging.getLogger("data_collector")
class DataCollector:
"""Класс для сбора и обновления данных"""
def __init__(self):
"""Инициализация сборщика данных"""
self.moex_data = MOEXHistoricalData()
self.news_parser = NewsParser()
self.financial_manager = FinancialDataManager()
self.news_manager = NewsManager()
self.analytics_manager = AnalyticsManager()
# Создаем директории для баз данных, если они не существуют
os.makedirs('data', exist_ok=True)
def _init_databases(self):
"""Инициализация баз данных"""
# Инициализация базы данных для финансовых данных
conn = sqlite3.connect(f"data/{DB_CONFIG['financial_data']}")
cursor = conn.cursor()
# Создаем таблицу для секторных индексов
cursor.execute('''
CREATE TABLE IF NOT EXISTS sector_indices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sector TEXT NOT NULL,
date DATE NOT NULL,
open REAL,
high REAL,
low REAL,
close REAL,
volume REAL,
UNIQUE(sector, date)
)
''')
# Создаем таблицу для данных по тикерам
cursor.execute('''
CREATE TABLE IF NOT EXISTS ticker_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ticker TEXT NOT NULL,
sector TEXT NOT NULL,
date DATE NOT NULL,
open REAL,
high REAL,
low REAL,
close REAL,
volume REAL,
UNIQUE(ticker, date)
)
''')
conn.commit()
conn.close()
# Инициализация базы данных для новостей
conn = sqlite3.connect(f"data/{DB_CONFIG['news_data']}")
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS news (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
topic TEXT,
title TEXT NOT NULL,
content TEXT NOT NULL,
url TEXT,
UNIQUE(title, date)
)
''')
conn.commit()
conn.close()
# Инициализация базы данных для аналитики
conn = sqlite3.connect(f"data/{DB_CONFIG['analytics']}")
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS analytics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
summary TEXT NOT NULL,
business_problems TEXT,
solutions TEXT,
sentiment REAL,
UNIQUE(date)
)
''')
conn.commit()
conn.close()
logger.info("Базы данных успешно инициализированы")
async def _collect_sector_data(self, days=30):
"""Сбор данных по секторным индексам"""
logger.info("Начинаем сбор данных по секторным индексам")
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# Подключаемся к базе данных
conn = sqlite3.connect(f"data/{DB_CONFIG['financial_data']}")
# Собираем данные по каждому сектору
for sector, index_code in sector_indices.items():
try:
logger.info(f"Получаем данные для сектора: {sector}")
# Получаем данные по индексу
df = await self.moex_data.get_official_sector_index(
sector,
start_date.strftime('%Y-%m-%d'),
end_date.strftime('%Y-%m-%d')
)
if not df.empty:
# Добавляем колонку с названием сектора
df['sector'] = sector
# Сохраняем данные в базу
df.to_sql('sector_indices', conn, if_exists='append', index=False,
dtype={
'sector': 'TEXT',
'TRADEDATE': 'DATE',
'OPEN': 'REAL',
'HIGH': 'REAL',
'LOW': 'REAL',
'CLOSE': 'REAL',
'VOLUME': 'REAL'
})
logger.info(f"Сохранено {len(df)} записей для сектора {sector}")
else:
logger.warning(f"Нет данных для сектора {sector}")
except Exception as e:
logger.error(f"Ошибка при сборе данных для сектора {sector}: {str(e)}")
conn.close()
logger.info("Сбор данных по секторным индексам завершен")
async def _collect_ticker_data(self, days=30):
"""Сбор данных по отдельным тикерам"""
logger.info("Начинаем сбор данных по тикерам")
end_date = datetime.now()
start_date = end_date - timedelta(days=days)
# Подключаемся к базе данных
conn = sqlite3.connect(f"data/{DB_CONFIG['financial_data']}")
# Собираем данные по каждому тикеру в каждом секторе
for sector, tickers in sector_tickers.items():
for ticker in tickers:
try:
logger.info(f"Получаем данные для тикера: {ticker} (сектор: {sector})")
# Получаем данные по тикеру
df = await self.moex_data.get_security_history(
ticker,
start_date.strftime('%Y-%m-%d'),
end_date.strftime('%Y-%m-%d')
)
if not df.empty:
# Добавляем колонки с тикером и сектором
df['ticker'] = ticker
df['sector'] = sector
# Сохраняем данные в базу
df.to_sql('ticker_data', conn, if_exists='append', index=False,
dtype={
'ticker': 'TEXT',
'sector': 'TEXT',
'TRADEDATE': 'DATE',
'OPEN': 'REAL',
'HIGH': 'REAL',
'LOW': 'REAL',
'CLOSE': 'REAL',
'VOLUME': 'REAL'
})
logger.info(f"Сохранено {len(df)} записей для тикера {ticker}")
else:
logger.warning(f"Нет данных для тикера {ticker}")
except Exception as e:
logger.error(f"Ошибка при сборе данных для тикера {ticker}: {str(e)}")
conn.close()
logger.info("Сбор данных по тикерам завершен")
def _collect_news(self, days=7):
"""Сбор новостей"""
logger.info(f"Начинаем сбор новостей за последние {days} дней")
try:
# Получаем новости
news_df = self.news_parser.parse_news(days)
if not news_df.empty:
# Подключаемся к базе данных
conn = sqlite3.connect(f"data/{DB_CONFIG['news_data']}")
# Сохраняем новости в базу
news_df.to_sql('news', conn, if_exists='append', index=False,
dtype={
'date': 'DATE',
'topic': 'TEXT',
'title': 'TEXT',
'content': 'TEXT',
'url': 'TEXT'
})
conn.close()
logger.info(f"Сохранено {len(news_df)} новостей")
else:
logger.warning("Нет новых новостей для сохранения")
except Exception as e:
logger.error(f"Ошибка при сборе новостей: {str(e)}")
def _analyze_news(self):
"""Анализ новостей"""
logger.info("Начинаем анализ новостей")
try:
# Получаем новости за последние 24 часа
yesterday = datetime.now() - timedelta(days=1)
news = self.news_manager.get_news_by_date(yesterday.strftime('%Y-%m-%d'))
if news:
# Анализируем новости
analysis_result = self.analytics_manager.analyze_news(news)
if analysis_result:
logger.info("Анализ новостей успешно завершен")
else:
logger.warning("Не удалось выполнить анализ новостей")
else:
logger.warning("Нет новостей для анализа")
except Exception as e:
logger.error(f"Ошибка при анализе новостей: {str(e)}")
async def collect_initial_data(self):
"""Сбор начальных данных при первом запуске"""
logger.info("Начинаем сбор начальных данных")
# Инициализируем базы данных
self._init_databases()
# Собираем финансовые данные за последние 90 дней
await self._collect_sector_data(days=90)
await self._collect_ticker_data(days=90)
# Собираем новости за последние 14 дней
self._collect_news(days=14)
# Анализируем новости
self._analyze_news()
logger.info("Сбор начальных данных завершен")
async def update_data(self):
"""Обновление данных по расписанию"""
logger.info("Начинаем обновление данных")
# Обновляем финансовые данные за последние 2 дня
await self._collect_sector_data(days=2)
await self._collect_ticker_data(days=2)
# Обновляем новости за последний день
self._collect_news(days=1)
logger.info("Обновление данных завершено")
def schedule_tasks(self):
"""Настройка расписания для регулярного обновления данных"""
logger.info("Настраиваем расписание для обновления данных")
# Обновление финансовых данных
schedule.every(DATA_COLLECTION['financial_interval_hours']).hours.do(
lambda: asyncio.run(self.update_data())
)
# Обновление новостей
schedule.every(DATA_COLLECTION['news_interval_hours']).hours.do(
lambda: self._collect_news(days=1)
)
# Анализ новостей
schedule.every(DATA_COLLECTION['analytics_interval_hours']).hours.do(
lambda: self._analyze_news()
)
logger.info("Расписание настроено")
# Запускаем бесконечный цикл для выполнения задач по расписанию
while True:
schedule.run_pending()
time.sleep(60)
async def main():
"""Основная функция"""
parser = argparse.ArgumentParser(description='Сборщик данных для финансово-аналитической платформы')
parser.add_argument('--init', action='store_true', help='Инициализировать базы данных и собрать начальные данные')
parser.add_argument('--update', action='store_true', help='Обновить данные')
parser.add_argument('--schedule', action='store_true', help='Запустить сбор данных по расписанию')
args = parser.parse_args()
collector = DataCollector()
if args.init:
await collector.collect_initial_data()
elif args.update:
await collector.update_data()
elif args.schedule:
collector.schedule_tasks()
else:
parser.print_help()
if __name__ == "__main__":
asyncio.run(main())

37
frontend/package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "finance-analytics-platform",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^14.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.0",
"chart.js": "^4.4.0",
"react-chartjs-2": "^5.2.0",
"react-markdown": "^9.0.0",
"date-fns": "^2.30.0",
"@mui/material": "^5.14.18",
"@mui/icons-material": "^5.14.18",
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"swr": "^2.2.4"
},
"devDependencies": {
"@types/node": "^20.9.0",
"@types/react": "^18.2.37",
"@types/react-dom": "^18.2.15",
"typescript": "^5.2.2",
"eslint": "^8.53.0",
"eslint-config-next": "^14.0.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5"
}
}

View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -0,0 +1,126 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
CircularProgress,
Alert,
TextField,
Button,
Grid,
Paper
} from '@mui/material';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { ru } from 'date-fns/locale';
import { format } from 'date-fns';
import AnalyticsView from '@/components/AnalyticsView';
import { getLatestAnalytics, getAnalyticsByDate, Analytics } from '@/lib/api';
export default function AnalyticsPage() {
const [analytics, setAnalytics] = useState<Analytics | null>(null);
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Загрузка последнего анализа при монтировании компонента
useEffect(() => {
const fetchLatestAnalytics = async () => {
try {
setLoading(true);
const data = await getLatestAnalytics();
setAnalytics(data);
// Устанавливаем дату последнего анализа
if (data.date) {
setSelectedDate(new Date(data.date));
}
} catch (err) {
console.error('Ошибка при загрузке аналитики:', err);
setError('Не удалось загрузить последний анализ.');
} finally {
setLoading(false);
}
};
fetchLatestAnalytics();
}, []);
const handleDateChange = (date: Date | null) => {
setSelectedDate(date);
};
const handleSearch = async () => {
if (!selectedDate) return;
try {
setLoading(true);
setError(null);
const formattedDate = format(selectedDate, 'yyyy-MM-dd');
const data = await getAnalyticsByDate(formattedDate);
setAnalytics(data);
} catch (err) {
console.error('Ошибка при загрузке аналитики по дате:', err);
setError('Не удалось загрузить анализ за выбранную дату.');
} finally {
setLoading(false);
}
};
return (
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Бизнес-аналитика
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Paper elevation={3} sx={{ p: 3, mb: 4 }}>
<Grid container spacing={2} alignItems="center">
<Grid item xs={12} md={6}>
<LocalizationProvider dateAdapter={AdapterDateFns} adapterLocale={ru}>
<DatePicker
label="Выберите дату"
value={selectedDate}
onChange={handleDateChange}
format="dd.MM.yyyy"
slotProps={{ textField: { fullWidth: true } }}
/>
</LocalizationProvider>
</Grid>
<Grid item xs={12} md={6}>
<Button
variant="contained"
color="primary"
onClick={handleSearch}
fullWidth
disabled={!selectedDate}
>
Найти анализ
</Button>
</Grid>
</Grid>
</Paper>
{loading ? (
<Box display="flex" justifyContent="center" alignItems="center" height={300}>
<CircularProgress />
</Box>
) : analytics ? (
<AnalyticsView analytics={analytics} />
) : (
<Alert severity="info">
Анализ не найден. Попробуйте выбрать другую дату.
</Alert>
)}
</Box>
);
}

View File

@ -0,0 +1,32 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--foreground-rgb: 0, 0, 0;
--background-rgb: 255, 255, 255;
}
body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
}
/* Стили для скроллбара */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #0072ff;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #005bcc;
}

View File

@ -0,0 +1,54 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import Header from '@/components/Header';
import { Box, Container, CssBaseline, ThemeProvider, createTheme } from '@mui/material';
const inter = Inter({ subsets: ['latin', 'cyrillic'] });
export const metadata: Metadata = {
title: 'Финансово-аналитическая платформа',
description: 'Платформа для анализа финансовых данных и новостей',
};
// Создаем тему
const theme = createTheme({
palette: {
primary: {
main: '#0072ff',
},
secondary: {
main: '#7b7b7b',
},
},
typography: {
fontFamily: inter.style.fontFamily,
},
});
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ru">
<body className={inter.className}>
<ThemeProvider theme={theme}>
<CssBaseline />
<Box display="flex" flexDirection="column" minHeight="100vh">
<Header />
<Container component="main" maxWidth="xl" sx={{ flexGrow: 1, py: 4 }}>
{children}
</Container>
<Box component="footer" sx={{ p: 2, bgcolor: 'secondary.100', textAlign: 'center' }}>
<Container>
© {new Date().getFullYear()} Финансово-аналитическая платформа
</Container>
</Box>
</Box>
</ThemeProvider>
</body>
</html>
);
}

View File

@ -0,0 +1,259 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Grid,
Paper,
CircularProgress,
Alert,
Tabs,
Tab,
FormControl,
InputLabel,
Select,
MenuItem,
SelectChangeEvent,
Slider,
Stack
} from '@mui/material';
import ChartComponent from '@/components/ChartComponent';
import { getSectors, getTickers, getSectorData, getTickerData, SectorData, TickerData } from '@/lib/api';
export default function Markets() {
const [sectors, setSectors] = useState<string[]>([]);
const [tickers, setTickers] = useState<string[]>([]);
const [selectedSector, setSelectedSector] = useState<string>('');
const [selectedTicker, setSelectedTicker] = useState<string>('');
const [sectorData, setSectorData] = useState<SectorData | null>(null);
const [tickerData, setTickerData] = useState<TickerData | null>(null);
const [period, setPeriod] = useState<number>(30);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Преобразуем названия секторов для отображения
const sectorNames: { [key: string]: string } = {
'metals_mining': 'Металлы и добыча',
'oil_gas': 'Нефть и газ',
'chemicals': 'Химия и нефтехимия',
'electric_utilities': 'Электроэнергетика',
'telecom': 'Телекоммуникации',
'finance': 'Финансы',
'consumer': 'Потребительский сектор',
'transport': 'Транспорт'
};
// Загрузка списка секторов при монтировании компонента
useEffect(() => {
const fetchSectors = async () => {
try {
setLoading(true);
const data = await getSectors();
setSectors(data);
if (data.length > 0) {
setSelectedSector(data[0]);
}
} catch (err) {
console.error('Ошибка при загрузке секторов:', err);
setError('Не удалось загрузить список секторов.');
} finally {
setLoading(false);
}
};
fetchSectors();
}, []);
// Загрузка тикеров при изменении выбранного сектора
useEffect(() => {
if (!selectedSector) return;
const fetchTickers = async () => {
try {
const data = await getTickers(selectedSector);
setTickers(data);
if (data.length > 0) {
setSelectedTicker(data[0]);
} else {
setSelectedTicker('');
}
} catch (err) {
console.error('Ошибка при загрузке тикеров:', err);
setError('Не удалось загрузить список тикеров.');
}
};
fetchTickers();
}, [selectedSector]);
// Загрузка данных по сектору при изменении выбранного сектора или периода
useEffect(() => {
if (!selectedSector) return;
const fetchSectorData = async () => {
try {
setLoading(true);
const data = await getSectorData(selectedSector, period);
setSectorData(data);
} catch (err) {
console.error('Ошибка при загрузке данных сектора:', err);
setError('Не удалось загрузить данные сектора.');
} finally {
setLoading(false);
}
};
fetchSectorData();
}, [selectedSector, period]);
// Загрузка данных по тикеру при изменении выбранного тикера или периода
useEffect(() => {
if (!selectedTicker) return;
const fetchTickerData = async () => {
try {
setLoading(true);
const data = await getTickerData(selectedTicker, period);
setTickerData(data);
} catch (err) {
console.error('Ошибка при загрузке данных тикера:', err);
setError('Не удалось загрузить данные тикера.');
} finally {
setLoading(false);
}
};
fetchTickerData();
}, [selectedTicker, period]);
const handleSectorChange = (event: SelectChangeEvent) => {
setSelectedSector(event.target.value);
};
const handleTickerChange = (event: SelectChangeEvent) => {
setSelectedTicker(event.target.value);
};
const handlePeriodChange = (event: Event, newValue: number | number[]) => {
setPeriod(newValue as number);
};
return (
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Рынки
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Paper elevation={3} sx={{ p: 3, mb: 4 }}>
<Grid container spacing={3}>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel id="sector-select-label">Сектор</InputLabel>
<Select
labelId="sector-select-label"
id="sector-select"
value={selectedSector}
label="Сектор"
onChange={handleSectorChange}
>
{sectors.map((sector) => (
<MenuItem key={sector} value={sector}>
{sectorNames[sector] || sector}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={4}>
<FormControl fullWidth>
<InputLabel id="ticker-select-label">Тикер</InputLabel>
<Select
labelId="ticker-select-label"
id="ticker-select"
value={selectedTicker}
label="Тикер"
onChange={handleTickerChange}
disabled={tickers.length === 0}
>
{tickers.map((ticker) => (
<MenuItem key={ticker} value={ticker}>
{ticker}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={4}>
<Typography gutterBottom>
Период: {period} дней
</Typography>
<Slider
value={period}
onChange={handlePeriodChange}
min={7}
max={365}
step={1}
marks={[
{ value: 7, label: '7д' },
{ value: 30, label: '30д' },
{ value: 90, label: '90д' },
{ value: 180, label: '180д' },
{ value: 365, label: '365д' },
]}
/>
</Grid>
</Grid>
</Paper>
<Grid container spacing={4}>
{/* Сектор */}
<Grid item xs={12} md={6}>
{loading && !sectorData ? (
<Box display="flex" justifyContent="center" alignItems="center" height={300}>
<CircularProgress />
</Box>
) : sectorData ? (
<ChartComponent
title={`Индекс: ${sectorNames[selectedSector] || selectedSector}`}
data={sectorData.data}
/>
) : (
<Alert severity="info">
Нет данных для отображения.
</Alert>
)}
</Grid>
{/* Тикер */}
<Grid item xs={12} md={6}>
{loading && !tickerData ? (
<Box display="flex" justifyContent="center" alignItems="center" height={300}>
<CircularProgress />
</Box>
) : tickerData ? (
<ChartComponent
title={`Тикер: ${tickerData.ticker}`}
data={tickerData.data}
color="rgb(75, 192, 192)"
/>
) : (
<Alert severity="info">
Выберите тикер для отображения данных.
</Alert>
)}
</Grid>
</Grid>
</Box>
);
}

View File

@ -0,0 +1,139 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
CircularProgress,
Alert,
FormControl,
InputLabel,
Select,
MenuItem,
SelectChangeEvent,
Slider,
Grid,
Paper
} from '@mui/material';
import NewsList from '@/components/NewsList';
import { getNews, getNewsTopics, NewsItem } from '@/lib/api';
export default function News() {
const [news, setNews] = useState<NewsItem[]>([]);
const [topics, setTopics] = useState<string[]>([]);
const [selectedTopic, setSelectedTopic] = useState<string>('all');
const [days, setDays] = useState<number>(7);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// Загрузка тем новостей при монтировании компонента
useEffect(() => {
const fetchTopics = async () => {
try {
const data = await getNewsTopics();
setTopics(data);
} catch (err) {
console.error('Ошибка при загрузке тем новостей:', err);
setError('Не удалось загрузить темы новостей.');
}
};
fetchTopics();
}, []);
// Загрузка новостей при изменении выбранной темы или периода
useEffect(() => {
const fetchNews = async () => {
try {
setLoading(true);
const topic = selectedTopic === 'all' ? undefined : selectedTopic;
const data = await getNews(days, topic);
setNews(data);
} catch (err) {
console.error('Ошибка при загрузке новостей:', err);
setError('Не удалось загрузить новости.');
} finally {
setLoading(false);
}
};
fetchNews();
}, [selectedTopic, days]);
const handleTopicChange = (event: SelectChangeEvent) => {
setSelectedTopic(event.target.value);
};
const handleDaysChange = (event: Event, newValue: number | number[]) => {
setDays(newValue as number);
};
return (
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Новости
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Paper elevation={3} sx={{ p: 3, mb: 4 }}>
<Grid container spacing={3}>
<Grid item xs={12} md={6}>
<FormControl fullWidth>
<InputLabel id="topic-select-label">Тема</InputLabel>
<Select
labelId="topic-select-label"
id="topic-select"
value={selectedTopic}
label="Тема"
onChange={handleTopicChange}
>
<MenuItem value="all">Все темы</MenuItem>
{topics.map((topic) => (
<MenuItem key={topic} value={topic}>
{topic}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} md={6}>
<Typography gutterBottom>
Период: {days} дней
</Typography>
<Slider
value={days}
onChange={handleDaysChange}
min={1}
max={30}
step={1}
marks={[
{ value: 1, label: '1д' },
{ value: 7, label: '7д' },
{ value: 14, label: '14д' },
{ value: 30, label: '30д' },
]}
/>
</Grid>
</Grid>
</Paper>
{loading ? (
<Box display="flex" justifyContent="center" alignItems="center" height={300}>
<CircularProgress />
</Box>
) : news.length > 0 ? (
<NewsList news={news} topics={topics} />
) : (
<Alert severity="info">
Новости не найдены. Попробуйте изменить параметры поиска.
</Alert>
)}
</Box>
);
}

145
frontend/src/app/page.tsx Normal file
View File

@ -0,0 +1,145 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Typography,
Grid,
Paper,
CircularProgress,
Alert,
Tabs,
Tab
} from '@mui/material';
import ChartComponent from '@/components/ChartComponent';
import NewsList from '@/components/NewsList';
import AnalyticsView from '@/components/AnalyticsView';
import { getDashboardData, DashboardData } from '@/lib/api';
export default function Home() {
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [selectedSector, setSelectedSector] = useState<string>('');
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const data = await getDashboardData();
setDashboardData(data);
// Устанавливаем первый сектор как выбранный по умолчанию
if (data.sectors_data && Object.keys(data.sectors_data).length > 0) {
setSelectedSector(Object.keys(data.sectors_data)[0]);
}
} catch (err) {
console.error('Ошибка при загрузке данных:', err);
setError('Не удалось загрузить данные. Пожалуйста, попробуйте позже.');
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const handleSectorChange = (event: React.SyntheticEvent, newValue: string) => {
setSelectedSector(newValue);
};
if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="50vh">
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Alert severity="error" sx={{ mt: 2 }}>
{error}
</Alert>
);
}
if (!dashboardData) {
return (
<Alert severity="info" sx={{ mt: 2 }}>
Нет данных для отображения.
</Alert>
);
}
// Получаем данные выбранного сектора
const selectedSectorData = selectedSector ? dashboardData.sectors_data[selectedSector] : null;
// Преобразуем названия секторов для отображения
const sectorNames: { [key: string]: string } = {
'metals_mining': 'Металлы и добыча',
'oil_gas': 'Нефть и газ',
'chemicals': 'Химия и нефтехимия',
'electric_utilities': 'Электроэнергетика',
'telecom': 'Телекоммуникации',
'finance': 'Финансы',
'consumer': 'Потребительский сектор',
'transport': 'Транспорт'
};
return (
<Box>
<Typography variant="h4" component="h1" gutterBottom>
Финансово-аналитическая платформа
</Typography>
<Grid container spacing={4}>
{/* Левая колонка - Финансовые данные */}
<Grid item xs={12} md={6}>
<Paper elevation={3} sx={{ p: 2, mb: 3 }}>
<Typography variant="h5" component="h2" gutterBottom>
Индексы секторов
</Typography>
<Tabs
value={selectedSector}
onChange={handleSectorChange}
variant="scrollable"
scrollButtons="auto"
sx={{ mb: 2 }}
>
{Object.keys(dashboardData.sectors_data).map((sector) => (
<Tab
key={sector}
label={sectorNames[sector] || sector}
value={sector}
/>
))}
</Tabs>
{selectedSectorData && (
<ChartComponent
title={sectorNames[selectedSector] || selectedSector}
data={selectedSectorData}
/>
)}
</Paper>
</Grid>
{/* Правая колонка - Новости и аналитика */}
<Grid item xs={12} md={6}>
{dashboardData.analytics && (
<AnalyticsView analytics={dashboardData.analytics} />
)}
{dashboardData.latest_news && dashboardData.latest_news.length > 0 && (
<NewsList
news={dashboardData.latest_news}
topics={Array.from(new Set(dashboardData.latest_news.map(item => item.topic)))}
/>
)}
</Grid>
</Grid>
</Box>
);
}

View File

@ -0,0 +1,128 @@
import React from 'react';
import {
Box,
Typography,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
Divider
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import { Analytics } from '@/lib/api';
import ReactMarkdown from 'react-markdown';
interface AnalyticsViewProps {
analytics: Analytics;
}
const AnalyticsView: React.FC<AnalyticsViewProps> = ({ analytics }) => {
// Функция для форматирования текста аналитики в нормальный вид
const formatAnalyticsText = (text: string) => {
return text
.replace(/\*\*/g, '') // Удаляем маркеры жирного текста
.replace(/\*\s*\*/g, ''); // Удаляем пустые маркеры
};
// Функция для парсинга проблем и решений
const parseProblemsAndSolutions = (text: string) => {
const problems = [];
let currentProblem = null;
const lines = text.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('Проблема №') || line.startsWith('Проблема:')) {
// Если начинается новая проблема, сохраняем предыдущую
if (currentProblem) {
problems.push(currentProblem);
}
currentProblem = {
problem: line.replace(/^Проблема №\d+:\s*|^Проблема:\s*/, '').trim(),
solution: ''
};
} else if (line.startsWith('Решение:') && currentProblem) {
// Если это решение для текущей проблемы
currentProblem.solution = line.replace(/^Решение:\s*/, '').trim();
// Если есть следующие строки, которые не начинаются с "Проблема", добавляем их к решению
let j = i + 1;
while (j < lines.length &&
!lines[j].trim().startsWith('Проблема №') &&
!lines[j].trim().startsWith('Проблема:')) {
if (lines[j].trim()) {
currentProblem.solution += '\n' + lines[j].trim();
}
j++;
}
// Переходим к следующей проблеме
i = j - 1;
}
}
// Добавляем последнюю проблему
if (currentProblem) {
problems.push(currentProblem);
}
return problems;
};
const formattedSummary = formatAnalyticsText(analytics.summary);
const problems = parseProblemsAndSolutions(analytics.problems_solutions);
return (
<Paper elevation={3} sx={{ p: 3, mb: 4 }}>
<Typography variant="h5" component="h2" gutterBottom>
Бизнес-аналитика на {format(new Date(analytics.date), 'dd MMMM yyyy', { locale: ru })}
</Typography>
<Box mb={4}>
<Typography variant="h6" component="h3" gutterBottom sx={{ color: 'primary.main' }}>
Краткая сводка новостей:
</Typography>
<Typography variant="body1" paragraph>
{formattedSummary}
</Typography>
</Box>
<Divider sx={{ mb: 3 }} />
<Typography variant="h6" component="h3" gutterBottom sx={{ color: 'primary.main', mb: 2 }}>
Выявленные бизнес-проблемы и решения:
</Typography>
{problems.map((item, index) => (
<Accordion key={index} sx={{ mb: 2 }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
backgroundColor: 'secondary.50',
'&:hover': { backgroundColor: 'secondary.100' }
}}
>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
Проблема {index + 1}: {item.problem}
</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography variant="h6" color="primary" gutterBottom>
Решение:
</Typography>
<ReactMarkdown>
{item.solution}
</ReactMarkdown>
</AccordionDetails>
</Accordion>
))}
</Paper>
);
};
export default AnalyticsView;

View File

@ -0,0 +1,107 @@
import React from 'react';
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
} from 'chart.js';
import { Line } from 'react-chartjs-2';
import { Box, Paper, Typography } from '@mui/material';
// Регистрируем необходимые компоненты для ChartJS
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend
);
interface ChartComponentProps {
title: string;
data: Array<{
TRADEDATE: string;
CLOSE: number;
[key: string]: any;
}>;
color?: string;
}
const ChartComponent: React.FC<ChartComponentProps> = ({ title, data, color = 'rgb(0, 114, 255)' }) => {
// Подготавливаем данные для графика
const chartData = {
labels: data.map(item => item.TRADEDATE),
datasets: [
{
label: title,
data: data.map(item => item.CLOSE),
borderColor: color,
backgroundColor: `${color}33`, // Добавляем прозрачность
tension: 0.1,
fill: true,
},
],
};
// Опции для графика
const options = {
responsive: true,
plugins: {
legend: {
position: 'top' as const,
},
title: {
display: true,
text: title,
},
},
scales: {
x: {
ticks: {
maxTicksLimit: 10, // Ограничиваем количество меток по оси X
},
},
},
};
// Вычисляем изменение цены
const firstPrice = data.length > 0 ? data[0].CLOSE : 0;
const lastPrice = data.length > 0 ? data[data.length - 1].CLOSE : 0;
const priceChange = lastPrice - firstPrice;
const priceChangePercent = firstPrice !== 0 ? (priceChange / firstPrice) * 100 : 0;
return (
<Paper elevation={3} sx={{ p: 2, mb: 3 }}>
<Typography variant="h6" component="h3" gutterBottom>
{title}
</Typography>
<Box display="flex" justifyContent="space-between" mb={2}>
<Typography variant="body1">
Цена: {lastPrice.toFixed(2)}
</Typography>
<Typography
variant="body1"
sx={{
color: priceChange >= 0 ? 'success.main' : 'error.main',
fontWeight: 'bold'
}}
>
{priceChange >= 0 ? '+' : ''}{priceChange.toFixed(2)} ({priceChangePercent.toFixed(2)}%)
</Typography>
</Box>
<Box height={300}>
<Line options={options} data={chartData} />
</Box>
</Paper>
);
};
export default ChartComponent;

View File

@ -0,0 +1,59 @@
import * as React from 'react';
import { AppBar, Toolbar, Typography, Button, Container, Box } from '@mui/material';
import { useRouter } from 'next/navigation';
const Header: React.FC = () => {
const router = useRouter();
return (
<AppBar position="static" sx={{ backgroundColor: '#0072ff' }}>
<Container maxWidth="xl">
<Toolbar disableGutters>
<Typography
variant="h6"
noWrap
component="div"
onClick={() => router.push('/')}
sx={{
mr: 2,
display: { xs: 'none', md: 'flex' },
fontWeight: 700,
cursor: 'pointer'
}}
>
ФИНАНСОВО-АНАЛИТИЧЕСКАЯ ПЛАТФОРМА
</Typography>
<Box sx={{ flexGrow: 1, display: { xs: 'none', md: 'flex' } }}>
<Button
onClick={() => router.push('/')}
sx={{ my: 2, color: 'white', display: 'block' }}
>
Главная
</Button>
<Button
onClick={() => router.push('/markets')}
sx={{ my: 2, color: 'white', display: 'block' }}
>
Рынки
</Button>
<Button
onClick={() => router.push('/news')}
sx={{ my: 2, color: 'white', display: 'block' }}
>
Новости
</Button>
<Button
onClick={() => router.push('/analytics')}
sx={{ my: 2, color: 'white', display: 'block' }}
>
Аналитика
</Button>
</Box>
</Toolbar>
</Container>
</AppBar>
);
};
export default Header;

View File

@ -0,0 +1,126 @@
import React, { useState } from 'react';
import {
Box,
Typography,
List,
ListItem,
ListItemText,
Chip,
Divider,
Paper,
Accordion,
AccordionSummary,
AccordionDetails,
FormControl,
InputLabel,
Select,
MenuItem,
SelectChangeEvent
} from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
import { NewsItem } from '@/lib/api';
interface NewsListProps {
news: NewsItem[];
topics?: string[];
}
const NewsList: React.FC<NewsListProps> = ({ news, topics = [] }) => {
const [selectedTopic, setSelectedTopic] = useState<string>('all');
const handleTopicChange = (event: SelectChangeEvent<string>) => {
setSelectedTopic(event.target.value);
};
// Фильтруем новости по выбранной теме
const filteredNews = selectedTopic === 'all'
? news
: news.filter(item => item.topic === selectedTopic);
return (
<Paper elevation={3} sx={{ p: 3, mb: 4 }}>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={3}>
<Typography variant="h5" component="h2" gutterBottom>
Последние новости
</Typography>
{topics.length > 0 && (
<FormControl variant="outlined" size="small" sx={{ minWidth: 200 }}>
<InputLabel id="topic-select-label">Тема</InputLabel>
<Select
labelId="topic-select-label"
id="topic-select"
value={selectedTopic}
onChange={handleTopicChange}
label="Тема"
>
<MenuItem value="all">Все темы</MenuItem>
{topics.map((topic) => (
<MenuItem key={topic} value={topic}>
{topic}
</MenuItem>
))}
</Select>
</FormControl>
)}
</Box>
{filteredNews.length === 0 ? (
<Typography variant="body1" color="text.secondary" sx={{ textAlign: 'center', py: 4 }}>
Новости не найдены
</Typography>
) : (
<List>
{filteredNews.map((item, index) => (
<React.Fragment key={item.id}>
{index > 0 && <Divider component="li" />}
<ListItem alignItems="flex-start" sx={{ py: 2 }}>
<ListItemText
primary={
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6" component="h3" sx={{ fontWeight: 'bold', mb: 1 }}>
{item.title}
</Typography>
<Chip
label={item.topic}
color="primary"
size="small"
sx={{ fontSize: '0.75rem' }}
/>
</Box>
}
secondary={
<>
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 1, display: 'block' }}
>
{format(new Date(item.date), 'dd MMMM yyyy', { locale: ru })}
</Typography>
<Accordion>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography>Подробнее...</Typography>
</AccordionSummary>
<AccordionDetails>
<Typography variant="body2">
{item.content}
</Typography>
</AccordionDetails>
</Accordion>
</>
}
/>
</ListItem>
</React.Fragment>
))}
</List>
)}
</Paper>
);
};
export default NewsList;

136
frontend/src/lib/api.ts Normal file
View File

@ -0,0 +1,136 @@
import axios from 'axios';
// Базовый URL API
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
// Создаем экземпляр axios с базовым URL
const api = axios.create({
baseURL: API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Интерфейсы для типизации данных
export interface SectorData {
sector: string;
data: Array<{
TRADEDATE: string;
OPEN: number;
HIGH: number;
LOW: number;
CLOSE: number;
VOLUME: number;
[key: string]: any;
}>;
}
export interface TickerData {
ticker: string;
data: Array<{
TRADEDATE: string;
OPEN: number;
HIGH: number;
LOW: number;
CLOSE: number;
VOLUME: number;
[key: string]: any;
}>;
}
export interface NewsItem {
id: number;
date: string;
topic: string;
title: string;
content: string;
created_at: string;
}
export interface Analytics {
date: string;
summary: string;
problems_solutions: string;
}
export interface DashboardData {
analytics: Analytics;
sectors_data: {
[key: string]: Array<{
TRADEDATE: string;
OPEN: number;
HIGH: number;
LOW: number;
CLOSE: number;
VOLUME: number;
[key: string]: any;
}>;
};
latest_news: NewsItem[];
}
// API функции для получения данных
// Получение списка секторов
export const getSectors = async (): Promise<string[]> => {
const response = await api.get<string[]>('/sectors');
return response.data;
};
// Получение данных по сектору
export const getSectorData = async (sector: string, period: number = 30): Promise<SectorData> => {
const response = await api.get<SectorData>(`/sector/${sector}`, {
params: { period },
});
return response.data;
};
// Получение списка тикеров
export const getTickers = async (sector?: string): Promise<string[]> => {
const response = await api.get<string[]>('/tickers', {
params: { sector },
});
return response.data;
};
// Получение данных по тикеру
export const getTickerData = async (ticker: string, period: number = 30): Promise<TickerData> => {
const response = await api.get<TickerData>(`/ticker/${ticker}`, {
params: { period },
});
return response.data;
};
// Получение тем новостей
export const getNewsTopics = async (): Promise<string[]> => {
const response = await api.get<string[]>('/news/topics');
return response.data;
};
// Получение новостей
export const getNews = async (days: number = 7, topic?: string): Promise<NewsItem[]> => {
const response = await api.get<NewsItem[]>('/news', {
params: { days, topic },
});
return response.data;
};
// Получение последнего анализа
export const getLatestAnalytics = async (): Promise<Analytics> => {
const response = await api.get<Analytics>('/analytics/latest');
return response.data;
};
// Получение анализа по дате
export const getAnalyticsByDate = async (date: string): Promise<Analytics> => {
const response = await api.get<Analytics>(`/analytics/${date}`);
return response.data;
};
// Получение данных для дашборда
export const getDashboardData = async (): Promise<DashboardData> => {
const response = await api.get<DashboardData>('/dashboard');
return response.data;
};
export default api;

View File

@ -0,0 +1,39 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
primary: {
50: '#e6f1ff',
100: '#cce3ff',
200: '#99c7ff',
300: '#66aaff',
400: '#338eff',
500: '#0072ff',
600: '#005bcc',
700: '#004499',
800: '#002e66',
900: '#001733',
},
secondary: {
50: '#f5f5f5',
100: '#e9e9e9',
200: '#d9d9d9',
300: '#c4c4c4',
400: '#9d9d9d',
500: '#7b7b7b',
600: '#555555',
700: '#434343',
800: '#262626',
900: '#171717',
},
},
},
},
plugins: [],
}

383
models.py Normal file
View File

@ -0,0 +1,383 @@
import sqlite3
from datetime import datetime, timedelta
import pandas as pd
import logging
from typing import List, Dict, Any, Optional
import os
# Для работы с OpenAI API
from openai import OpenAI
# Импортируем конфигурацию
from config import sector_indices, sector_tickers, DB_CONFIG, OPENAI_CONFIG
# Настройка логирования
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger("models")
class FinancialDataManager:
"""Класс для управления финансовыми данными"""
def __init__(self):
"""Инициализация менеджера финансовых данных"""
self.db_path = f"data/{DB_CONFIG['financial_data']}"
def _get_connection(self):
"""
Создает и возвращает соединение с базой данных.
Returns:
sqlite3.Connection: Объект соединения с базой данных
"""
return sqlite3.connect(self.db_path)
def get_sector_data(self, sector: str, start_date: str, end_date: str) -> List[Dict[str, Any]]:
"""
Получение данных по сектору за указанный период
Args:
sector: Название сектора
start_date: Начальная дата в формате 'YYYY-MM-DD'
end_date: Конечная дата в формате 'YYYY-MM-DD'
Returns:
List[Dict[str, Any]]: Данные по сектору
"""
query = f"""
SELECT * FROM sector_indices
WHERE sector = ? AND date BETWEEN ? AND ?
ORDER BY date
"""
try:
with self._get_connection() as conn:
df = pd.read_sql_query(query, conn, params=(sector, start_date, end_date))
if not df.empty:
# Преобразуем даты в строки для JSON
df['date'] = df['date'].astype(str)
return df.to_dict(orient='records')
return []
except Exception as e:
logger.error(f"Ошибка при получении данных сектора {sector}: {str(e)}")
return []
def get_ticker_data(self, ticker: str, start_date: str, end_date: str) -> List[Dict[str, Any]]:
"""
Получение данных по тикеру за указанный период
Args:
ticker: Тикер ценной бумаги
start_date: Начальная дата в формате 'YYYY-MM-DD'
end_date: Конечная дата в формате 'YYYY-MM-DD'
Returns:
List[Dict[str, Any]]: Данные по тикеру
"""
query = f"""
SELECT * FROM ticker_data
WHERE ticker = ? AND date BETWEEN ? AND ?
ORDER BY date
"""
try:
with self._get_connection() as conn:
df = pd.read_sql_query(query, conn, params=(ticker, start_date, end_date))
if not df.empty:
# Преобразуем даты в строки для JSON
df['date'] = df['date'].astype(str)
return df.to_dict(orient='records')
return []
except Exception as e:
logger.error(f"Ошибка при получении данных тикера {ticker}: {str(e)}")
return []
def get_all_sectors(self) -> List[str]:
"""
Получение списка всех доступных секторов
Returns:
List[str]: Список секторов
"""
return list(sector_indices.keys())
def get_all_tickers(self) -> Dict[str, List[str]]:
"""
Получение списка всех доступных тикеров, сгруппированных по секторам
Returns:
Dict[str, List[str]]: Словарь с тикерами по секторам
"""
return sector_tickers
class NewsManager:
"""Класс для управления новостями"""
def __init__(self):
"""Инициализация менеджера новостей"""
self.db_path = f"data/{DB_CONFIG['news_data']}"
def _get_connection(self):
"""
Создает и возвращает соединение с базой данных.
Returns:
sqlite3.Connection: Объект соединения с базой данных
"""
return sqlite3.connect(self.db_path)
def get_news_by_date(self, start_date: str, end_date: str = None) -> List[Dict[str, Any]]:
"""
Получение новостей за указанный период
Args:
start_date: Начальная дата в формате 'YYYY-MM-DD'
end_date: Конечная дата в формате 'YYYY-MM-DD' (опционально)
Returns:
List[Dict[str, Any]]: Данные новостей
"""
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
query = """
SELECT * FROM news
WHERE date BETWEEN ? AND ?
ORDER BY date DESC
"""
try:
with self._get_connection() as conn:
df = pd.read_sql_query(query, conn, params=(start_date, end_date))
if not df.empty:
# Преобразуем даты в строки для JSON
df['date'] = df['date'].astype(str)
return df.to_dict(orient='records')
return []
except Exception as e:
logger.error(f"Ошибка при получении новостей: {str(e)}")
return []
def get_news_by_topic(self, topic: str, start_date: str, end_date: str = None) -> List[Dict[str, Any]]:
"""
Получение новостей по теме за указанный период
Args:
topic: Тема новостей
start_date: Начальная дата в формате 'YYYY-MM-DD'
end_date: Конечная дата в формате 'YYYY-MM-DD' (опционально)
Returns:
List[Dict[str, Any]]: Данные новостей
"""
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
query = """
SELECT * FROM news
WHERE topic = ? AND date BETWEEN ? AND ?
ORDER BY date DESC
"""
try:
with self._get_connection() as conn:
df = pd.read_sql_query(query, conn, params=(topic, start_date, end_date))
if not df.empty:
# Преобразуем даты в строки для JSON
df['date'] = df['date'].astype(str)
return df.to_dict(orient='records')
return []
except Exception as e:
logger.error(f"Ошибка при получении новостей по теме {topic}: {str(e)}")
return []
def get_topics(self) -> List[str]:
"""
Получение списка всех доступных тем новостей
Returns:
List[str]: Список тем
"""
query = "SELECT DISTINCT topic FROM news"
try:
with self._get_connection() as conn:
df = pd.read_sql_query(query, conn)
return df['topic'].tolist()
except Exception as e:
logger.error(f"Ошибка при получении списка тем новостей: {str(e)}")
return []
class AnalyticsManager:
"""Класс для управления аналитикой"""
def __init__(self):
"""Инициализация менеджера аналитики"""
self.db_path = f"data/{DB_CONFIG['analytics']}"
self.news_manager = NewsManager()
def _get_connection(self):
"""
Создает и возвращает соединение с базой данных.
Returns:
sqlite3.Connection: Объект соединения с базой данных
"""
return sqlite3.connect(self.db_path)
def analyze_news(self, news: List[Dict[str, Any]]) -> bool:
"""
Анализирует новости и сохраняет результаты в базу данных
Args:
news: Список новостей для анализа
Returns:
bool: Успешность операции
"""
if not news:
logger.warning("Нет новостей для анализа")
return False
# Собираем контент новостей для анализа
news_content = [item['content'] for item in news if 'content' in item]
if not news_content:
logger.warning("Нет содержимого новостей для анализа")
return False
# Подготавливаем промпт для анализа
prompt = """
Проанализируйте следующие новости и предоставьте:
1. Краткую сводку основных событий
2. Выявленные бизнес-проблемы
3. Возможные решения для этих проблем
4. Общую оценку настроения новостей (от -1 до 1, где -1 - крайне негативное, 1 - крайне позитивное)
Формат ответа:
{
"summary": "Краткая сводка основных событий...",
"business_problems": "Проблема 1... Проблема 2...",
"solutions": "Решение 1... Решение 2...",
"sentiment": 0.5
}
"""
try:
# Создаем клиент для OpenAI API
client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY", ""))
# Преобразуем массив новостей в строку
news_text = "\n\n".join(news_content)
# Отправляем запрос к API
completion = client.chat.completions.create(
model=OPENAI_CONFIG['model'],
messages=[
{"role": "system", "content": prompt},
{"role": "user", "content": news_text}
],
temperature=OPENAI_CONFIG['temperature'],
max_tokens=OPENAI_CONFIG['max_tokens'],
response_format={"type": "json_object"}
)
# Получаем результат анализа
analysis_result = completion.choices[0].message.content
# Парсим JSON-ответ
import json
result = json.loads(analysis_result)
# Создаем запись в базе данных
today = datetime.now().date().strftime("%Y-%m-%d")
with self._get_connection() as conn:
# Проверяем, есть ли уже анализ за сегодня
cursor = conn.cursor()
cursor.execute("SELECT id FROM analytics WHERE date = ?", (today,))
existing = cursor.fetchone()
if existing:
# Обновляем существующий анализ
cursor.execute(
"""
UPDATE analytics
SET summary = ?, business_problems = ?, solutions = ?, sentiment = ?
WHERE date = ?
""",
(
result.get("summary", ""),
result.get("business_problems", ""),
result.get("solutions", ""),
result.get("sentiment", 0),
today
)
)
logger.info(f"Анализ за {today} обновлен")
else:
# Создаем новый анализ
cursor.execute(
"""
INSERT INTO analytics (date, summary, business_problems, solutions, sentiment)
VALUES (?, ?, ?, ?, ?)
""",
(
today,
result.get("summary", ""),
result.get("business_problems", ""),
result.get("solutions", ""),
result.get("sentiment", 0)
)
)
logger.info(f"Анализ за {today} сохранен")
conn.commit()
return True
except Exception as e:
logger.error(f"Ошибка при анализе новостей: {str(e)}")
return False
def get_analytics_by_date(self, start_date: str, end_date: str = None) -> List[Dict[str, Any]]:
"""
Получение аналитики за указанный период
Args:
start_date: Начальная дата в формате 'YYYY-MM-DD'
end_date: Конечная дата в формате 'YYYY-MM-DD' (опционально)
Returns:
List[Dict[str, Any]]: Данные аналитики
"""
if not end_date:
end_date = datetime.now().strftime("%Y-%m-%d")
query = """
SELECT * FROM analytics
WHERE date BETWEEN ? AND ?
ORDER BY date DESC
"""
try:
with self._get_connection() as conn:
df = pd.read_sql_query(query, conn, params=(start_date, end_date))
if not df.empty:
# Преобразуем даты в строки для JSON
df['date'] = df['date'].astype(str)
return df.to_dict(orient='records')
return []
except Exception as e:
logger.error(f"Ошибка при получении аналитики: {str(e)}")
return []

90
news_parser.py Normal file
View File

@ -0,0 +1,90 @@
import datetime
import requests
from bs4 import BeautifulSoup
import pandas as pd
class NewsParser:
def __init__(self):
# Можно использовать один session для всех запросов
self.requests_session = requests.Session()
def parse_news(self, last_days=1):
"""
Парсит новости с Lenta.ru за указанное число последних дней.
:param last_days: количество последних дней, за которые нужно собрать новости.
:return: pd.DataFrame с колонками [dates, topics, titles, content].
"""
dates = []
topics = []
titles = []
contents = []
# Перебираем дни от 0 до last_days-1
for day in range(last_days):
num_pages = 1
# Формируем дату в нужном формате: 'YYYY/MM/DD'
date_str = (datetime.datetime.today() - datetime.timedelta(days=day)).strftime('%Y/%m/%d')
while True:
url = f'https://lenta.ru/{date_str}/page/{num_pages}/'
response = self.requests_session.get(url)
if response.status_code != 200:
break
soup = BeautifulSoup(response.text, 'lxml')
news_list = soup.find_all('a', {"class": "card-full-news _archive"}, href=True)
# Если на странице нет новостей, выходим из цикла пагинации
if len(news_list) == 0:
break
for news in news_list:
dates.append(date_str)
# Заголовок
title_el = news.find('h3', {"class": "card-full-news__title"})
if title_el:
titles.append(title_el.get_text(strip=True))
else:
titles.append('None')
# Рубрика/тема
topic_el = news.find('span', {"class": "card-full-news__info-item card-full-news__rubric"})
if topic_el:
topics.append(topic_el.get_text(strip=True))
else:
topics.append('None')
# Текст статьи
news_url = 'https://lenta.ru' + news['href']
article_req = self.requests_session.get(news_url)
article_soup = BeautifulSoup(article_req.text, 'lxml')
paragraphs = article_soup.find_all('p', class_='topic-body__content-text')
# Склеиваем все абзацы в одну строку
news_content = ' '.join([p.get_text(strip=True) for p in paragraphs])
contents.append(news_content)
num_pages += 1
# Формируем DataFrame
df = pd.DataFrame({
'dates': dates,
'topics': topics,
'titles': titles,
'content': contents
})
# Преобразуем колонки с датами к формату datetime
df['dates'] = pd.to_datetime(df['dates'], errors='coerce')
return df
if __name__ == "__main__":
# Пример использования
parser = NewsParser()
news_df = parser.parse_news() # Сбор новостей за последние 2 дня
print(news_df.head())

View File

@ -400,7 +400,7 @@
}
],
"source": [
"df_economy = df[(df['topics'] == 'Экономика') | (df['topics'] == 'Политика') | (df['topics'] == 'Интернет и СМИ')]\n",
"df_economy = df[(df['topics'] == 'Экономика') | (df['topics'] == 'Политика')]\n",
"df_economy\n"
]
},

View File

@ -1,19 +1,15 @@
pandas
plotly
requests
apimoex
aiohttp
requests
httpx
pybit
aiohttp
altair
pymc
scikit-learn
numpy
seaborn
statsmodels
xgboost
lightgbm
groq
yfinance
fastapi==0.104.1
uvicorn==0.23.2
pandas==2.1.1
numpy==1.26.0
requests==2.31.0
beautifulsoup4==4.12.2
lxml==4.9.3
openai==1.3.0
schedule==1.2.0
python-dateutil==2.8.2
pydantic==2.4.2
sqlalchemy==2.0.22
aiohttp==3.8.6
asyncio==3.4.3
python-multipart==0.0.6