Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 requests
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import numpy as np
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Добавляем родительскую директорию в путь для импорта
|
||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
from config import sector_indices
|
||||
|
||||
|
||||
class MOEXHistoricalData:
|
||||
"""Класс для получения исторических данных с Московской биржи"""
|
||||
|
||||
def __init__(self):
|
||||
"""Инициализация класса для работы с историческими данными MOEX"""
|
||||
self.base_url = "https://iss.moex.com/iss"
|
||||
|
||||
# Словарь соответствия секторов и их индексов на MOEX
|
||||
self.sector_indices = {
|
||||
'metals_mining': 'MOEXMM', # Индекс Металлов и добычи
|
||||
'oil_gas': 'MOEXOG', # Индекс Нефти и газа
|
||||
'chemicals': 'MOEXCH', # Индекс Химии и нефтехимии
|
||||
'electric_utilities': 'MOEXEU', # Индекс Электроэнергетики
|
||||
'telecom': 'MOEXTL', # Индекс Телекоммуникаций
|
||||
'finance': 'MOEXFN', # Индекс Финансов
|
||||
'consumer': 'MOEXCN', # Индекс Потребительского сектора
|
||||
'transport': 'MOEXTN' # Индекс Транспорта
|
||||
}
|
||||
|
||||
async def get_security_history(
|
||||
self,
|
||||
ticker: str,
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
engine: str = "stock",
|
||||
market: str = "shares",
|
||||
board: str = "TQBR"
|
||||
) -> pd.DataFrame:
|
||||
|
||||
async def _make_request(self, url: str) -> dict:
|
||||
"""
|
||||
Получение исторических данных по отдельному тикеру
|
||||
Выполняет асинхронный запрос к API MOEX
|
||||
|
||||
Args:
|
||||
ticker: Тикер акции
|
||||
start_date: Начальная дата в формате YYYY-MM-DD
|
||||
end_date: Конечная дата в формате YYYY-MM-DD
|
||||
engine: Торговый движок (по умолчанию stock)
|
||||
market: Рынок (по умолчанию shares)
|
||||
board: Режим торгов (по умолчанию TQBR)
|
||||
url: URL для запроса
|
||||
|
||||
Returns:
|
||||
DataFrame с историческими данными
|
||||
dict: Ответ от API в формате JSON
|
||||
"""
|
||||
url = f"{self.base_url}/history/engines/{engine}/markets/{market}/boards/{board}/securities/{ticker}.json"
|
||||
|
||||
all_data = []
|
||||
start = 0
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while True:
|
||||
params = {
|
||||
"from": start_date,
|
||||
"till": end_date,
|
||||
"start": start,
|
||||
"limit": 100
|
||||
}
|
||||
|
||||
async with session.get(url, params=params) as response:
|
||||
data = await response.json()
|
||||
|
||||
# Получаем данные истории
|
||||
history_data = data['history']
|
||||
|
||||
if not history_data['data']:
|
||||
break
|
||||
|
||||
# Добавляем данные в общий список
|
||||
all_data.extend(history_data['data'])
|
||||
start += 100
|
||||
|
||||
if len(history_data['data']) < 100:
|
||||
break
|
||||
|
||||
# Создаем DataFrame
|
||||
df = pd.DataFrame(all_data, columns=history_data['columns'])
|
||||
|
||||
# Конвертируем даты и числовые значения
|
||||
df['TRADEDATE'] = pd.to_datetime(df['TRADEDATE'])
|
||||
numeric_columns = ['OPEN', 'HIGH', 'LOW', 'CLOSE', 'VALUE', 'VOLUME']
|
||||
df[numeric_columns] = df[numeric_columns].apply(pd.to_numeric)
|
||||
|
||||
return df
|
||||
|
||||
async def get_sector_history(
|
||||
self,
|
||||
sector_tickers: List[str],
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
engine: str = "stock",
|
||||
market: str = "shares",
|
||||
board: str = "TQBR"
|
||||
) -> Dict[str, pd.DataFrame]:
|
||||
async with session.get(url) as response:
|
||||
if response.status == 200:
|
||||
return await response.json()
|
||||
else:
|
||||
raise Exception(f"Ошибка при запросе к API MOEX: {response.status}")
|
||||
|
||||
async def get_official_sector_index(self, sector: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||||
"""
|
||||
Получение исторических данных по всем тикерам сектора
|
||||
Получает исторические данные по официальному индексу сектора
|
||||
|
||||
Args:
|
||||
sector_tickers: Список тикеров сектора
|
||||
start_date: Начальная дата в формате YYYY-MM-DD
|
||||
end_date: Конечная дата в формате YYYY-MM-DD
|
||||
engine: Торговый движок (по умолчанию stock)
|
||||
market: Рынок (по умолчанию shares)
|
||||
board: Режим торгов (по умолчанию TQBR)
|
||||
sector: Название сектора
|
||||
start_date: Начальная дата в формате 'YYYY-MM-DD'
|
||||
end_date: Конечная дата в формате 'YYYY-MM-DD'
|
||||
|
||||
Returns:
|
||||
Словарь {тикер: DataFrame с историческими данными}
|
||||
pd.DataFrame: Исторические данные по индексу
|
||||
"""
|
||||
tasks = []
|
||||
for ticker in sector_tickers:
|
||||
task = self.get_security_history(
|
||||
ticker=ticker,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
engine=engine,
|
||||
market=market,
|
||||
board=board
|
||||
)
|
||||
tasks.append(task)
|
||||
# Получаем код индекса для сектора
|
||||
index_code = sector_indices.get(sector)
|
||||
|
||||
if not index_code:
|
||||
raise ValueError(f"Неизвестный сектор: {sector}")
|
||||
|
||||
# Формируем URL для запроса
|
||||
url = f"{self.base_url}/history/engines/stock/markets/index/securities/{index_code}/candles.json"
|
||||
url += f"?from={start_date}&till={end_date}&interval=24"
|
||||
|
||||
# Выполняем запрос
|
||||
response = await self._make_request(url)
|
||||
|
||||
# Преобразуем ответ в DataFrame
|
||||
if 'candles' in response and 'data' in response['candles']:
|
||||
df = pd.DataFrame(response['candles']['data'], columns=response['candles']['columns'])
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
return dict(zip(sector_tickers, results))
|
||||
|
||||
async def get_official_sector_index(
|
||||
self,
|
||||
sector: str,
|
||||
start_date: str,
|
||||
end_date: str
|
||||
) -> pd.DataFrame:
|
||||
# Переименовываем колонки для соответствия формату
|
||||
df = df.rename(columns={
|
||||
'begin': 'TRADEDATE',
|
||||
'open': 'OPEN',
|
||||
'high': 'HIGH',
|
||||
'low': 'LOW',
|
||||
'close': 'CLOSE',
|
||||
'volume': 'VOLUME'
|
||||
})
|
||||
|
||||
# Преобразуем дату в формат datetime
|
||||
df['TRADEDATE'] = pd.to_datetime(df['TRADEDATE']).dt.date
|
||||
|
||||
return df
|
||||
else:
|
||||
return pd.DataFrame()
|
||||
|
||||
async def get_security_history(self, ticker: str, start_date: str, end_date: str) -> pd.DataFrame:
|
||||
"""
|
||||
Получение официального отраслевого индекса с MOEX
|
||||
Получает исторические данные по ценной бумаге
|
||||
|
||||
Args:
|
||||
sector: Название сектора (ключ из словаря sector_indices)
|
||||
start_date: Начальная дата в формате YYYY-MM-DD
|
||||
end_date: Конечная дата в формате YYYY-MM-DD
|
||||
ticker: Тикер ценной бумаги
|
||||
start_date: Начальная дата в формате 'YYYY-MM-DD'
|
||||
end_date: Конечная дата в формате 'YYYY-MM-DD'
|
||||
|
||||
Returns:
|
||||
DataFrame с данными индекса
|
||||
pd.DataFrame: Исторические данные по ценной бумаге
|
||||
"""
|
||||
if sector not in self.sector_indices:
|
||||
raise ValueError(f"Неизвестный сектор: {sector}. Доступные секторы: {list(self.sector_indices.keys())}")
|
||||
# Формируем URL для запроса
|
||||
url = f"{self.base_url}/history/engines/stock/markets/shares/securities/{ticker}/candles.json"
|
||||
url += f"?from={start_date}&till={end_date}&interval=24"
|
||||
|
||||
# Выполняем запрос
|
||||
response = await self._make_request(url)
|
||||
|
||||
# Преобразуем ответ в DataFrame
|
||||
if 'candles' in response and 'data' in response['candles']:
|
||||
df = pd.DataFrame(response['candles']['data'], columns=response['candles']['columns'])
|
||||
|
||||
index_ticker = self.sector_indices[sector]
|
||||
url = f"{self.base_url}/history/engines/stock/markets/index/securities/{index_ticker}.json"
|
||||
|
||||
all_data = []
|
||||
start = 0
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
while True:
|
||||
params = {
|
||||
"from": start_date,
|
||||
"till": end_date,
|
||||
"start": start,
|
||||
"limit": 100
|
||||
}
|
||||
|
||||
async with session.get(url, params=params) as response:
|
||||
data = await response.json()
|
||||
|
||||
history_data = data['history']
|
||||
|
||||
if not history_data['data']:
|
||||
break
|
||||
|
||||
all_data.extend(history_data['data'])
|
||||
start += 100
|
||||
|
||||
if len(history_data['data']) < 100:
|
||||
break
|
||||
|
||||
df = pd.DataFrame(all_data, columns=history_data['columns'])
|
||||
|
||||
# Конвертируем даты и числовые значения
|
||||
df['TRADEDATE'] = pd.to_datetime(df['TRADEDATE'])
|
||||
numeric_columns = ['OPEN', 'HIGH', 'LOW', 'CLOSE', 'VALUE', 'VOLUME']
|
||||
df[numeric_columns] = df[numeric_columns].apply(pd.to_numeric)
|
||||
|
||||
return df
|
||||
# Переименовываем колонки для соответствия формату
|
||||
df = df.rename(columns={
|
||||
'begin': 'TRADEDATE',
|
||||
'open': 'OPEN',
|
||||
'high': 'HIGH',
|
||||
'low': 'LOW',
|
||||
'close': 'CLOSE',
|
||||
'volume': 'VOLUME'
|
||||
})
|
||||
|
||||
# Преобразуем дату в формат datetime
|
||||
df['TRADEDATE'] = pd.to_datetime(df['TRADEDATE']).dt.date
|
||||
|
||||
return df
|
||||
else:
|
||||
return pd.DataFrame()
|
||||
|
||||
def calculate_sector_index(
|
||||
self,
|
||||
sector_data: Dict[str, pd.DataFrame],
|
||||
weights: Optional[Dict[str, float]] = None
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Расчет индекса сектора на основе исторических данных входящих в него компаний
|
||||
|
||||
# Пример использования
|
||||
if __name__ == "__main__":
|
||||
async def test():
|
||||
moex = MOEXHistoricalData()
|
||||
|
||||
Args:
|
||||
sector_data: Словарь с историческими данными по тикерам {тикер: DataFrame}
|
||||
weights: Словарь с весами компаний {тикер: вес}. Если None, веса будут равными
|
||||
|
||||
Returns:
|
||||
DataFrame с рассчитанным индексом сектора
|
||||
"""
|
||||
# Если веса не указаны, используем равные веса
|
||||
if weights is None:
|
||||
weights = {ticker: 1/len(sector_data) for ticker in sector_data.keys()}
|
||||
|
||||
# Создаем DataFrame с датами и ценами закрытия для каждого тикера
|
||||
prices_df = pd.DataFrame()
|
||||
# Получаем данные по индексу нефти и газа за последний месяц
|
||||
end_date = datetime.now()
|
||||
start_date = end_date - timedelta(days=30)
|
||||
|
||||
for ticker, df in sector_data.items():
|
||||
prices_df[ticker] = df.set_index('TRADEDATE')['CLOSE']
|
||||
|
||||
# Рассчитываем относительное изменение цен
|
||||
returns_df = prices_df.pct_change()
|
||||
df = await moex.get_official_sector_index(
|
||||
'oil_gas',
|
||||
start_date.strftime('%Y-%m-%d'),
|
||||
end_date.strftime('%Y-%m-%d')
|
||||
)
|
||||
|
||||
# Рассчитываем взвешенную доходность индекса
|
||||
weighted_returns = pd.DataFrame()
|
||||
for ticker in returns_df.columns:
|
||||
weighted_returns[ticker] = returns_df[ticker] * weights[ticker]
|
||||
|
||||
index_returns = weighted_returns.sum(axis=1)
|
||||
|
||||
# Рассчитываем значения индекса
|
||||
index_values = (1 + index_returns).cumprod() * 1000 # Начальное значение 1000
|
||||
|
||||
# Создаем итоговый DataFrame
|
||||
result_df = pd.DataFrame({
|
||||
'INDEX_VALUE': index_values,
|
||||
'INDEX_RETURN': index_returns
|
||||
})
|
||||
|
||||
return result_df
|
||||
print(df.head())
|
||||
|
||||
asyncio.run(test())
|
||||
307
config.py
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',
|
||||
'AMEZ',
|
||||
'BELO',
|
||||
'BLNG',
|
||||
'CHEP',
|
||||
'CHMF',
|
||||
'CHMK',
|
||||
'CHZN',
|
||||
'ENPG',
|
||||
'GMKN',
|
||||
'KBTK',
|
||||
'KOGK',
|
||||
'LNZL',
|
||||
'LNZLP',
|
||||
'MAGN',
|
||||
'MGOK',
|
||||
'MTLR',
|
||||
'MTLRP',
|
||||
'NLMK',
|
||||
'PGIL',
|
||||
'PLZL',
|
||||
'PMTL',
|
||||
'POGR',
|
||||
'POLY',
|
||||
'RASP',
|
||||
'RUAL',
|
||||
'RUALR',
|
||||
'SELG',
|
||||
'SELGP',
|
||||
'TRMK',
|
||||
'UGLD',
|
||||
'UNKL',
|
||||
'VSMO',
|
||||
'VSMZ'],
|
||||
'oil_gas': ['BANE',
|
||||
'BANEP',
|
||||
'GAZP',
|
||||
'JNOSP',
|
||||
'KRKNP',
|
||||
'LKOH',
|
||||
'MFGS',
|
||||
'MFGSP',
|
||||
'NOTK',
|
||||
'NVTK',
|
||||
'RITK',
|
||||
'RNFT',
|
||||
'RNHSP',
|
||||
'ROSN',
|
||||
'SIBN',
|
||||
'SNGS',
|
||||
'SNGSP',
|
||||
'TATN',
|
||||
'TATNP',
|
||||
'TNBP',
|
||||
'TNBPP',
|
||||
'TRMK',
|
||||
'TRNFP'],
|
||||
'chemicals': ['AKRN',
|
||||
'AZKM',
|
||||
'DGBZ',
|
||||
'DGBZP',
|
||||
'KAZT',
|
||||
'KZOS',
|
||||
'KZOSP',
|
||||
'MGNZ',
|
||||
'NKNC',
|
||||
'NKNCP',
|
||||
'OMSH',
|
||||
'PHOR',
|
||||
'SILV',
|
||||
'URKA',
|
||||
'YASH'],
|
||||
'electric_utilities': ['ARSB',
|
||||
'BEGY',
|
||||
'DVEC',
|
||||
'EESR',
|
||||
'EESRP',
|
||||
'ELFV',
|
||||
'ENRU',
|
||||
'EONR',
|
||||
'FEES',
|
||||
'HYDR',
|
||||
'IRAO',
|
||||
'IRGZ',
|
||||
'KISB',
|
||||
'KRNG',
|
||||
'KRSG',
|
||||
'LSNG',
|
||||
'LSNGP',
|
||||
'MGSV',
|
||||
'MRKC',
|
||||
'MRKH',
|
||||
'MRKK',
|
||||
'MRKP',
|
||||
'MRKS',
|
||||
'MRKU',
|
||||
'MRKV',
|
||||
'MRKY',
|
||||
'MRKZ',
|
||||
'MSNG',
|
||||
'MSRS',
|
||||
'MSSB',
|
||||
'MSSV',
|
||||
'OGK1',
|
||||
'OGK2',
|
||||
'OGK4',
|
||||
'OGK6',
|
||||
'OGKA',
|
||||
'OGKB',
|
||||
'OGKC',
|
||||
'OGKD',
|
||||
'OGKE',
|
||||
'OGKF',
|
||||
'RSTI',
|
||||
'RSTIP',
|
||||
'SAGO',
|
||||
'SARE',
|
||||
'SVER',
|
||||
'TGKA',
|
||||
'TGKB',
|
||||
'TGKD',
|
||||
'TGKE',
|
||||
'TGKF',
|
||||
'TGKH',
|
||||
'TGKI',
|
||||
'TGKJ',
|
||||
'TGKN',
|
||||
'TNSE',
|
||||
'UPRO',
|
||||
'VRAO',
|
||||
'VTGK',
|
||||
'YKEN'],
|
||||
'telecom': ['AFKC',
|
||||
'AFKS',
|
||||
'BISV',
|
||||
'BISVP',
|
||||
'CMST',
|
||||
'CNTL',
|
||||
'CNTLP',
|
||||
'CTLK',
|
||||
'DLSV',
|
||||
'DLSVP',
|
||||
'MFON',
|
||||
'MGTS',
|
||||
'MGTSP',
|
||||
'MTSI',
|
||||
'MTSS',
|
||||
'RTKM',
|
||||
'RTKMP',
|
||||
'SPTL',
|
||||
'SPTLP',
|
||||
'STKM',
|
||||
'STKMP',
|
||||
'TTLK',
|
||||
'URSI',
|
||||
'URSIP',
|
||||
'UTEL',
|
||||
'VTEL',
|
||||
'VTELP'],
|
||||
'finance': ['AFKS',
|
||||
'BSPB',
|
||||
'BSPBP',
|
||||
'CBOM',
|
||||
'EPLN',
|
||||
'FTRE',
|
||||
'LEAS',
|
||||
'MBNK',
|
||||
'MMBM',
|
||||
'MOEX',
|
||||
'PSBR',
|
||||
'QIWI',
|
||||
'RENI',
|
||||
'ROSB',
|
||||
'SBER',
|
||||
'SBERP',
|
||||
'SFIN',
|
||||
'SPBE',
|
||||
'T',
|
||||
'TAVR',
|
||||
'TCSG',
|
||||
'TRHN',
|
||||
'URSAP',
|
||||
'VTBR',
|
||||
'VTBS',
|
||||
'VZRZ',
|
||||
'VZRZP',
|
||||
'YRSL',
|
||||
'ZAYM'],
|
||||
'consumer': ['ABIO',
|
||||
'AGRO',
|
||||
'APTK',
|
||||
'AQUA',
|
||||
'AVAZ',
|
||||
'AVAZP',
|
||||
'BELU',
|
||||
'DELI',
|
||||
'DIXY',
|
||||
'DSKY',
|
||||
'EUTR',
|
||||
'FIVE',
|
||||
'FIXP',
|
||||
'GCHE',
|
||||
'GEMC',
|
||||
'GRAZ',
|
||||
'HNFG',
|
||||
'ISKJ',
|
||||
'KLNA',
|
||||
'LENT',
|
||||
'LIFE',
|
||||
'LNTA',
|
||||
'MDMG',
|
||||
'MGNT',
|
||||
'MVID',
|
||||
'OBUV',
|
||||
'OKEY',
|
||||
'ORUP',
|
||||
'OTCP',
|
||||
'PHST',
|
||||
'PKBA',
|
||||
'PKBAP',
|
||||
'PRMD',
|
||||
'PRTK',
|
||||
'ROST',
|
||||
'RSEA',
|
||||
'SCOH',
|
||||
'SCON',
|
||||
'SVAV',
|
||||
'SYNG',
|
||||
'VFRM',
|
||||
'VRPH',
|
||||
'VSEH',
|
||||
'WBDF',
|
||||
'WUSH',
|
||||
'YNDX'],
|
||||
'transport': ['AFLT',
|
||||
'FESH',
|
||||
'FLOT',
|
||||
'GLTR',
|
||||
'GTRK',
|
||||
'NKHP',
|
||||
'NMTP',
|
||||
'TAER',
|
||||
'TRCN',
|
||||
'UTAR']}
|
||||
# Словарь соответствия секторов и их официальных индексов на MOEX
|
||||
sector_indices = {
|
||||
'oil_gas': 'MOEXOG', # Нефть и газ
|
||||
'electric': 'MOEXEU', # Электроэнергетика
|
||||
'telecom': 'MOEXTL', # Телекоммуникации
|
||||
'metals': 'MOEXMM', # Металлы и добыча
|
||||
'finance': 'MOEXFN', # Финансы
|
||||
'consumer': 'MOEXCN', # Потребительский сектор
|
||||
'chemicals': 'MOEXCH', # Химия и нефтехимия
|
||||
'transport': 'MOEXTN', # Транспорт
|
||||
'it': 'MOEXIT' # Информационные технологии
|
||||
}
|
||||
|
||||
# Словарь соответствия секторов и тикеров компаний, входящих в них
|
||||
sector_tickers = {
|
||||
'oil_gas': ['GAZP', 'LKOH', 'ROSN', 'SNGS', 'TATN', 'NVTK'],
|
||||
'electric': ['IRAO', 'MSNG', 'RSTI', 'HYDR', 'FEES', 'UPRO'],
|
||||
'telecom': ['MTSS', 'RTKM', 'MGTS', 'MFON'],
|
||||
'metals': ['GMKN', 'NLMK', 'MAGN', 'CHMF', 'PLZL', 'POLY', 'ALRS'],
|
||||
'finance': ['SBER', 'VTBR', 'CBOM', 'MOEX', 'TCSG'],
|
||||
'consumer': ['MGNT', 'FIVE', 'DSKY', 'FIXP', 'OZON'],
|
||||
'chemicals': ['PHOR', 'AKRN', 'NKNC', 'KAZT'],
|
||||
'transport': ['AFLT', 'RTKM', 'NMTP'],
|
||||
'it': ['YNDX', 'QIWI', 'CIAN', 'VKCO']
|
||||
}
|
||||
|
||||
# Настройки базы данных
|
||||
DB_CONFIG = {
|
||||
'financial_data': 'moex_data.db',
|
||||
'news_data': 'news_data.db',
|
||||
'analytics': 'analytics.db'
|
||||
}
|
||||
|
||||
# Настройки сбора данных
|
||||
DATA_COLLECTION = {
|
||||
'financial_interval_hours': 12,
|
||||
'news_interval_hours': 2,
|
||||
'analytics_interval_hours': 24
|
||||
}
|
||||
|
||||
# Настройки API
|
||||
API_CONFIG = {
|
||||
'host': '0.0.0.0',
|
||||
'port': 8000
|
||||
}
|
||||
|
||||
# Настройки для OpenAI API (для анализа новостей)
|
||||
OPENAI_CONFIG = {
|
||||
'model': 'gpt-3.5-turbo',
|
||||
'temperature': 0.3,
|
||||
'max_tokens': 500
|
||||
}
|
||||
0
data_collector.log
Normal file
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": [
|
||||
"df_economy = df[(df['topics'] == 'Экономика') | (df['topics'] == 'Политика') | (df['topics'] == 'Интернет и СМИ')]\n",
|
||||
"df_economy = df[(df['topics'] == 'Экономика') | (df['topics'] == 'Политика')]\n",
|
||||
"df_economy\n"
|
||||
]
|
||||
},
|
||||
|
||||
@ -1,19 +1,15 @@
|
||||
pandas
|
||||
plotly
|
||||
requests
|
||||
apimoex
|
||||
aiohttp
|
||||
requests
|
||||
httpx
|
||||
pybit
|
||||
aiohttp
|
||||
altair
|
||||
pymc
|
||||
scikit-learn
|
||||
numpy
|
||||
seaborn
|
||||
statsmodels
|
||||
xgboost
|
||||
lightgbm
|
||||
groq
|
||||
yfinance
|
||||
fastapi==0.104.1
|
||||
uvicorn==0.23.2
|
||||
pandas==2.1.1
|
||||
numpy==1.26.0
|
||||
requests==2.31.0
|
||||
beautifulsoup4==4.12.2
|
||||
lxml==4.9.3
|
||||
openai==1.3.0
|
||||
schedule==1.2.0
|
||||
python-dateutil==2.8.2
|
||||
pydantic==2.4.2
|
||||
sqlalchemy==2.0.22
|
||||
aiohttp==3.8.6
|
||||
asyncio==3.4.3
|
||||
python-multipart==0.0.6
|
||||
Loading…
Reference in New Issue
Block a user