Рефакторинг конфигурации и структуры проекта
- Обновлен config.py с оптимизированными словарями секторов и индексов - Удалены устаревшие классы exchange.py и moex_class.py - Модернизирован moex_history.py с улучшенной логикой получения данных - Обновлен requirements.txt с современными зависимостями для финансовой платформы - Упрощен open_router.ipynb с фокусом на экономических темах
This commit is contained in:
parent
61b6f03e26
commit
391757d581
98
README.md
Normal file
98
README.md
Normal 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
236
api.py
Normal 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
|
||||||
|
)
|
||||||
@ -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])
|
|
||||||
|
|
||||||
|
|
||||||
@ -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)
|
|
||||||
@ -1,228 +1,141 @@
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import requests
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from typing import List, Dict, Optional
|
|
||||||
import asyncio
|
|
||||||
import aiohttp
|
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:
|
class MOEXHistoricalData:
|
||||||
|
"""Класс для получения исторических данных с Московской биржи"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
"""Инициализация класса для работы с историческими данными MOEX"""
|
||||||
self.base_url = "https://iss.moex.com/iss"
|
self.base_url = "https://iss.moex.com/iss"
|
||||||
|
|
||||||
# Словарь соответствия секторов и их индексов на MOEX
|
async def _make_request(self, url: str) -> dict:
|
||||||
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:
|
|
||||||
"""
|
"""
|
||||||
Получение исторических данных по отдельному тикеру
|
Выполняет асинхронный запрос к API MOEX
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ticker: Тикер акции
|
url: URL для запроса
|
||||||
start_date: Начальная дата в формате YYYY-MM-DD
|
|
||||||
end_date: Конечная дата в формате YYYY-MM-DD
|
|
||||||
engine: Торговый движок (по умолчанию stock)
|
|
||||||
market: Рынок (по умолчанию shares)
|
|
||||||
board: Режим торгов (по умолчанию TQBR)
|
|
||||||
|
|
||||||
Returns:
|
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:
|
async with aiohttp.ClientSession() as session:
|
||||||
while True:
|
async with session.get(url) as response:
|
||||||
params = {
|
if response.status == 200:
|
||||||
"from": start_date,
|
return await response.json()
|
||||||
"till": end_date,
|
else:
|
||||||
"start": start,
|
raise Exception(f"Ошибка при запросе к API MOEX: {response.status}")
|
||||||
"limit": 100
|
|
||||||
}
|
async def get_official_sector_index(self, sector: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||||||
|
|
||||||
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]:
|
|
||||||
"""
|
"""
|
||||||
Получение исторических данных по всем тикерам сектора
|
Получает исторические данные по официальному индексу сектора
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
sector_tickers: Список тикеров сектора
|
sector: Название сектора
|
||||||
start_date: Начальная дата в формате YYYY-MM-DD
|
start_date: Начальная дата в формате 'YYYY-MM-DD'
|
||||||
end_date: Конечная дата в формате YYYY-MM-DD
|
end_date: Конечная дата в формате 'YYYY-MM-DD'
|
||||||
engine: Торговый движок (по умолчанию stock)
|
|
||||||
market: Рынок (по умолчанию shares)
|
|
||||||
board: Режим торгов (по умолчанию TQBR)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Словарь {тикер: DataFrame с историческими данными}
|
pd.DataFrame: Исторические данные по индексу
|
||||||
"""
|
"""
|
||||||
tasks = []
|
# Получаем код индекса для сектора
|
||||||
for ticker in sector_tickers:
|
index_code = sector_indices.get(sector)
|
||||||
task = self.get_security_history(
|
|
||||||
ticker=ticker,
|
if not index_code:
|
||||||
start_date=start_date,
|
raise ValueError(f"Неизвестный сектор: {sector}")
|
||||||
end_date=end_date,
|
|
||||||
engine=engine,
|
# Формируем URL для запроса
|
||||||
market=market,
|
url = f"{self.base_url}/history/engines/stock/markets/index/securities/{index_code}/candles.json"
|
||||||
board=board
|
url += f"?from={start_date}&till={end_date}&interval=24"
|
||||||
)
|
|
||||||
tasks.append(task)
|
# Выполняем запрос
|
||||||
|
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))
|
df = df.rename(columns={
|
||||||
|
'begin': 'TRADEDATE',
|
||||||
async def get_official_sector_index(
|
'open': 'OPEN',
|
||||||
self,
|
'high': 'HIGH',
|
||||||
sector: str,
|
'low': 'LOW',
|
||||||
start_date: str,
|
'close': 'CLOSE',
|
||||||
end_date: str
|
'volume': 'VOLUME'
|
||||||
) -> pd.DataFrame:
|
})
|
||||||
|
|
||||||
|
# Преобразуем дату в формат 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:
|
Args:
|
||||||
sector: Название сектора (ключ из словаря sector_indices)
|
ticker: Тикер ценной бумаги
|
||||||
start_date: Начальная дата в формате YYYY-MM-DD
|
start_date: Начальная дата в формате 'YYYY-MM-DD'
|
||||||
end_date: Конечная дата в формате YYYY-MM-DD
|
end_date: Конечная дата в формате 'YYYY-MM-DD'
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DataFrame с данными индекса
|
pd.DataFrame: Исторические данные по ценной бумаге
|
||||||
"""
|
"""
|
||||||
if sector not in self.sector_indices:
|
# Формируем URL для запроса
|
||||||
raise ValueError(f"Неизвестный сектор: {sector}. Доступные секторы: {list(self.sector_indices.keys())}")
|
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"
|
df = df.rename(columns={
|
||||||
|
'begin': 'TRADEDATE',
|
||||||
all_data = []
|
'open': 'OPEN',
|
||||||
start = 0
|
'high': 'HIGH',
|
||||||
|
'low': 'LOW',
|
||||||
async with aiohttp.ClientSession() as session:
|
'close': 'CLOSE',
|
||||||
while True:
|
'volume': 'VOLUME'
|
||||||
params = {
|
})
|
||||||
"from": start_date,
|
|
||||||
"till": end_date,
|
# Преобразуем дату в формат datetime
|
||||||
"start": start,
|
df['TRADEDATE'] = pd.to_datetime(df['TRADEDATE']).dt.date
|
||||||
"limit": 100
|
|
||||||
}
|
return df
|
||||||
|
else:
|
||||||
async with session.get(url, params=params) as response:
|
return pd.DataFrame()
|
||||||
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
|
|
||||||
|
|
||||||
def calculate_sector_index(
|
|
||||||
self,
|
# Пример использования
|
||||||
sector_data: Dict[str, pd.DataFrame],
|
if __name__ == "__main__":
|
||||||
weights: Optional[Dict[str, float]] = None
|
async def test():
|
||||||
) -> pd.DataFrame:
|
moex = MOEXHistoricalData()
|
||||||
"""
|
|
||||||
Расчет индекса сектора на основе исторических данных входящих в него компаний
|
|
||||||
|
|
||||||
Args:
|
# Получаем данные по индексу нефти и газа за последний месяц
|
||||||
sector_data: Словарь с историческими данными по тикерам {тикер: DataFrame}
|
end_date = datetime.now()
|
||||||
weights: Словарь с весами компаний {тикер: вес}. Если None, веса будут равными
|
start_date = end_date - timedelta(days=30)
|
||||||
|
|
||||||
Returns:
|
|
||||||
DataFrame с рассчитанным индексом сектора
|
|
||||||
"""
|
|
||||||
# Если веса не указаны, используем равные веса
|
|
||||||
if weights is None:
|
|
||||||
weights = {ticker: 1/len(sector_data) for ticker in sector_data.keys()}
|
|
||||||
|
|
||||||
# Создаем DataFrame с датами и ценами закрытия для каждого тикера
|
|
||||||
prices_df = pd.DataFrame()
|
|
||||||
|
|
||||||
for ticker, df in sector_data.items():
|
df = await moex.get_official_sector_index(
|
||||||
prices_df[ticker] = df.set_index('TRADEDATE')['CLOSE']
|
'oil_gas',
|
||||||
|
start_date.strftime('%Y-%m-%d'),
|
||||||
# Рассчитываем относительное изменение цен
|
end_date.strftime('%Y-%m-%d')
|
||||||
returns_df = prices_df.pct_change()
|
)
|
||||||
|
|
||||||
# Рассчитываем взвешенную доходность индекса
|
print(df.head())
|
||||||
weighted_returns = pd.DataFrame()
|
|
||||||
for ticker in returns_df.columns:
|
asyncio.run(test())
|
||||||
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
|
|
||||||
307
config.py
307
config.py
@ -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',
|
# Словарь соответствия секторов и их официальных индексов на MOEX
|
||||||
'AMEZ',
|
sector_indices = {
|
||||||
'BELO',
|
'oil_gas': 'MOEXOG', # Нефть и газ
|
||||||
'BLNG',
|
'electric': 'MOEXEU', # Электроэнергетика
|
||||||
'CHEP',
|
'telecom': 'MOEXTL', # Телекоммуникации
|
||||||
'CHMF',
|
'metals': 'MOEXMM', # Металлы и добыча
|
||||||
'CHMK',
|
'finance': 'MOEXFN', # Финансы
|
||||||
'CHZN',
|
'consumer': 'MOEXCN', # Потребительский сектор
|
||||||
'ENPG',
|
'chemicals': 'MOEXCH', # Химия и нефтехимия
|
||||||
'GMKN',
|
'transport': 'MOEXTN', # Транспорт
|
||||||
'KBTK',
|
'it': 'MOEXIT' # Информационные технологии
|
||||||
'KOGK',
|
}
|
||||||
'LNZL',
|
|
||||||
'LNZLP',
|
# Словарь соответствия секторов и тикеров компаний, входящих в них
|
||||||
'MAGN',
|
sector_tickers = {
|
||||||
'MGOK',
|
'oil_gas': ['GAZP', 'LKOH', 'ROSN', 'SNGS', 'TATN', 'NVTK'],
|
||||||
'MTLR',
|
'electric': ['IRAO', 'MSNG', 'RSTI', 'HYDR', 'FEES', 'UPRO'],
|
||||||
'MTLRP',
|
'telecom': ['MTSS', 'RTKM', 'MGTS', 'MFON'],
|
||||||
'NLMK',
|
'metals': ['GMKN', 'NLMK', 'MAGN', 'CHMF', 'PLZL', 'POLY', 'ALRS'],
|
||||||
'PGIL',
|
'finance': ['SBER', 'VTBR', 'CBOM', 'MOEX', 'TCSG'],
|
||||||
'PLZL',
|
'consumer': ['MGNT', 'FIVE', 'DSKY', 'FIXP', 'OZON'],
|
||||||
'PMTL',
|
'chemicals': ['PHOR', 'AKRN', 'NKNC', 'KAZT'],
|
||||||
'POGR',
|
'transport': ['AFLT', 'RTKM', 'NMTP'],
|
||||||
'POLY',
|
'it': ['YNDX', 'QIWI', 'CIAN', 'VKCO']
|
||||||
'RASP',
|
}
|
||||||
'RUAL',
|
|
||||||
'RUALR',
|
# Настройки базы данных
|
||||||
'SELG',
|
DB_CONFIG = {
|
||||||
'SELGP',
|
'financial_data': 'moex_data.db',
|
||||||
'TRMK',
|
'news_data': 'news_data.db',
|
||||||
'UGLD',
|
'analytics': 'analytics.db'
|
||||||
'UNKL',
|
}
|
||||||
'VSMO',
|
|
||||||
'VSMZ'],
|
# Настройки сбора данных
|
||||||
'oil_gas': ['BANE',
|
DATA_COLLECTION = {
|
||||||
'BANEP',
|
'financial_interval_hours': 12,
|
||||||
'GAZP',
|
'news_interval_hours': 2,
|
||||||
'JNOSP',
|
'analytics_interval_hours': 24
|
||||||
'KRKNP',
|
}
|
||||||
'LKOH',
|
|
||||||
'MFGS',
|
# Настройки API
|
||||||
'MFGSP',
|
API_CONFIG = {
|
||||||
'NOTK',
|
'host': '0.0.0.0',
|
||||||
'NVTK',
|
'port': 8000
|
||||||
'RITK',
|
}
|
||||||
'RNFT',
|
|
||||||
'RNHSP',
|
# Настройки для OpenAI API (для анализа новостей)
|
||||||
'ROSN',
|
OPENAI_CONFIG = {
|
||||||
'SIBN',
|
'model': 'gpt-3.5-turbo',
|
||||||
'SNGS',
|
'temperature': 0.3,
|
||||||
'SNGSP',
|
'max_tokens': 500
|
||||||
'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']}
|
|
||||||
0
data_collector.log
Normal file
0
data_collector.log
Normal file
356
data_collector.py
Normal file
356
data_collector.py
Normal 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
37
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
126
frontend/src/app/analytics/page.tsx
Normal file
126
frontend/src/app/analytics/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
frontend/src/app/globals.css
Normal file
32
frontend/src/app/globals.css
Normal 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;
|
||||||
|
}
|
||||||
54
frontend/src/app/layout.tsx
Normal file
54
frontend/src/app/layout.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
259
frontend/src/app/markets/page.tsx
Normal file
259
frontend/src/app/markets/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
frontend/src/app/news/page.tsx
Normal file
139
frontend/src/app/news/page.tsx
Normal 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
145
frontend/src/app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
frontend/src/components/AnalyticsView.tsx
Normal file
128
frontend/src/components/AnalyticsView.tsx
Normal 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;
|
||||||
107
frontend/src/components/ChartComponent.tsx
Normal file
107
frontend/src/components/ChartComponent.tsx
Normal 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;
|
||||||
59
frontend/src/components/Header.tsx
Normal file
59
frontend/src/components/Header.tsx
Normal 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;
|
||||||
126
frontend/src/components/NewsList.tsx
Normal file
126
frontend/src/components/NewsList.tsx
Normal 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
136
frontend/src/lib/api.ts
Normal 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;
|
||||||
39
frontend/tailwind.config.js
Normal file
39
frontend/tailwind.config.js
Normal 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
383
models.py
Normal 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
90
news_parser.py
Normal 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())
|
||||||
@ -400,7 +400,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"source": [
|
"source": [
|
||||||
"df_economy = df[(df['topics'] == 'Экономика') | (df['topics'] == 'Политика') | (df['topics'] == 'Интернет и СМИ')]\n",
|
"df_economy = df[(df['topics'] == 'Экономика') | (df['topics'] == 'Политика')]\n",
|
||||||
"df_economy\n"
|
"df_economy\n"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,19 +1,15 @@
|
|||||||
pandas
|
fastapi==0.104.1
|
||||||
plotly
|
uvicorn==0.23.2
|
||||||
requests
|
pandas==2.1.1
|
||||||
apimoex
|
numpy==1.26.0
|
||||||
aiohttp
|
requests==2.31.0
|
||||||
requests
|
beautifulsoup4==4.12.2
|
||||||
httpx
|
lxml==4.9.3
|
||||||
pybit
|
openai==1.3.0
|
||||||
aiohttp
|
schedule==1.2.0
|
||||||
altair
|
python-dateutil==2.8.2
|
||||||
pymc
|
pydantic==2.4.2
|
||||||
scikit-learn
|
sqlalchemy==2.0.22
|
||||||
numpy
|
aiohttp==3.8.6
|
||||||
seaborn
|
asyncio==3.4.3
|
||||||
statsmodels
|
python-multipart==0.0.6
|
||||||
xgboost
|
|
||||||
lightgbm
|
|
||||||
groq
|
|
||||||
yfinance
|
|
||||||
Loading…
Reference in New Issue
Block a user