From 391757d581c3027702dc301e005592fb3f917615 Mon Sep 17 00:00:00 2001 From: belikovme Date: Wed, 12 Mar 2025 17:01:25 +0700 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D1=84=D0=B0=D0=BA=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D0=B3=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8=D0=B3?= =?UTF-8?q?=D1=83=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B8=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=8B=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B5=D0=BA=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Обновлен config.py с оптимизированными словарями секторов и индексов - Удалены устаревшие классы exchange.py и moex_class.py - Модернизирован moex_history.py с улучшенной логикой получения данных - Обновлен requirements.txt с современными зависимостями для финансовой платформы - Упрощен open_router.ipynb с фокусом на экономических темах --- README.md | 98 ++++++ api.py | 236 +++++++++++++ classes/exchange.py | 138 -------- classes/moex_class.py | 70 ---- classes/moex_history.py | 307 ++++++----------- config.py | 307 +++-------------- data_collector.log | 0 data_collector.py | 356 +++++++++++++++++++ frontend/package.json | 37 ++ frontend/postcss.config.js | 6 + frontend/src/app/analytics/page.tsx | 126 +++++++ frontend/src/app/globals.css | 32 ++ frontend/src/app/layout.tsx | 54 +++ frontend/src/app/markets/page.tsx | 259 ++++++++++++++ frontend/src/app/news/page.tsx | 139 ++++++++ frontend/src/app/page.tsx | 145 ++++++++ frontend/src/components/AnalyticsView.tsx | 128 +++++++ frontend/src/components/ChartComponent.tsx | 107 ++++++ frontend/src/components/Header.tsx | 59 ++++ frontend/src/components/NewsList.tsx | 126 +++++++ frontend/src/lib/api.ts | 136 ++++++++ frontend/tailwind.config.js | 39 +++ models.py | 383 +++++++++++++++++++++ news_parser.py | 90 +++++ open_router.ipynb | 2 +- requirements.txt | 34 +- 26 files changed, 2735 insertions(+), 679 deletions(-) create mode 100644 README.md create mode 100644 api.py delete mode 100644 classes/exchange.py delete mode 100644 classes/moex_class.py create mode 100644 data_collector.log create mode 100644 data_collector.py create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/app/analytics/page.tsx create mode 100644 frontend/src/app/globals.css create mode 100644 frontend/src/app/layout.tsx create mode 100644 frontend/src/app/markets/page.tsx create mode 100644 frontend/src/app/news/page.tsx create mode 100644 frontend/src/app/page.tsx create mode 100644 frontend/src/components/AnalyticsView.tsx create mode 100644 frontend/src/components/ChartComponent.tsx create mode 100644 frontend/src/components/Header.tsx create mode 100644 frontend/src/components/NewsList.tsx create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/tailwind.config.js create mode 100644 models.py create mode 100644 news_parser.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..0a2a81e --- /dev/null +++ b/README.md @@ -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** - База данных с результатами анализа новостей \ No newline at end of file diff --git a/api.py b/api.py new file mode 100644 index 0000000..8db2d46 --- /dev/null +++ b/api.py @@ -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 + ) \ No newline at end of file diff --git a/classes/exchange.py b/classes/exchange.py deleted file mode 100644 index 3d9cf88..0000000 --- a/classes/exchange.py +++ /dev/null @@ -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]) - - diff --git a/classes/moex_class.py b/classes/moex_class.py deleted file mode 100644 index 7f691fa..0000000 --- a/classes/moex_class.py +++ /dev/null @@ -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) diff --git a/classes/moex_history.py b/classes/moex_history.py index 8eebdde..1a07e40 100644 --- a/classes/moex_history.py +++ b/classes/moex_history.py @@ -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 \ No newline at end of file + print(df.head()) + + asyncio.run(test()) \ No newline at end of file diff --git a/config.py b/config.py index 413f04b..5d361ce 100644 --- a/config.py +++ b/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']} \ No newline at end of file +# Словарь соответствия секторов и их официальных индексов на 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 +} \ No newline at end of file diff --git a/data_collector.log b/data_collector.log new file mode 100644 index 0000000..e69de29 diff --git a/data_collector.py b/data_collector.py new file mode 100644 index 0000000..488bbed --- /dev/null +++ b/data_collector.py @@ -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()) \ No newline at end of file diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..d4a2dc7 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} \ No newline at end of file diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..96bb01e --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} \ No newline at end of file diff --git a/frontend/src/app/analytics/page.tsx b/frontend/src/app/analytics/page.tsx new file mode 100644 index 0000000..77c6e65 --- /dev/null +++ b/frontend/src/app/analytics/page.tsx @@ -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(null); + const [selectedDate, setSelectedDate] = useState(new Date()); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Бизнес-аналитика + + + {error && ( + + {error} + + )} + + + + + + + + + + + + + + + + {loading ? ( + + + + ) : analytics ? ( + + ) : ( + + Анализ не найден. Попробуйте выбрать другую дату. + + )} + + ); +} \ No newline at end of file diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css new file mode 100644 index 0000000..5803229 --- /dev/null +++ b/frontend/src/app/globals.css @@ -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; +} \ No newline at end of file diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx new file mode 100644 index 0000000..f48c009 --- /dev/null +++ b/frontend/src/app/layout.tsx @@ -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 ( + + + + + +
+ + {children} + + + + © {new Date().getFullYear()} Финансово-аналитическая платформа + + + + + + + ); +} \ No newline at end of file diff --git a/frontend/src/app/markets/page.tsx b/frontend/src/app/markets/page.tsx new file mode 100644 index 0000000..550a95a --- /dev/null +++ b/frontend/src/app/markets/page.tsx @@ -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([]); + const [tickers, setTickers] = useState([]); + const [selectedSector, setSelectedSector] = useState(''); + const [selectedTicker, setSelectedTicker] = useState(''); + const [sectorData, setSectorData] = useState(null); + const [tickerData, setTickerData] = useState(null); + const [period, setPeriod] = useState(30); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Рынки + + + {error && ( + + {error} + + )} + + + + + + Сектор + + + + + + + Тикер + + + + + + + Период: {period} дней + + + + + + + + {/* Сектор */} + + {loading && !sectorData ? ( + + + + ) : sectorData ? ( + + ) : ( + + Нет данных для отображения. + + )} + + + {/* Тикер */} + + {loading && !tickerData ? ( + + + + ) : tickerData ? ( + + ) : ( + + Выберите тикер для отображения данных. + + )} + + + + ); +} \ No newline at end of file diff --git a/frontend/src/app/news/page.tsx b/frontend/src/app/news/page.tsx new file mode 100644 index 0000000..5a4369f --- /dev/null +++ b/frontend/src/app/news/page.tsx @@ -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([]); + const [topics, setTopics] = useState([]); + const [selectedTopic, setSelectedTopic] = useState('all'); + const [days, setDays] = useState(7); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + Новости + + + {error && ( + + {error} + + )} + + + + + + Тема + + + + + + + Период: {days} дней + + + + + + + {loading ? ( + + + + ) : news.length > 0 ? ( + + ) : ( + + Новости не найдены. Попробуйте изменить параметры поиска. + + )} + + ); +} \ No newline at end of file diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx new file mode 100644 index 0000000..b86af9f --- /dev/null +++ b/frontend/src/app/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedSector, setSelectedSector] = useState(''); + + 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 ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!dashboardData) { + return ( + + Нет данных для отображения. + + ); + } + + // Получаем данные выбранного сектора + 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 ( + + + Финансово-аналитическая платформа + + + + {/* Левая колонка - Финансовые данные */} + + + + Индексы секторов + + + + {Object.keys(dashboardData.sectors_data).map((sector) => ( + + ))} + + + {selectedSectorData && ( + + )} + + + + {/* Правая колонка - Новости и аналитика */} + + {dashboardData.analytics && ( + + )} + + {dashboardData.latest_news && dashboardData.latest_news.length > 0 && ( + item.topic)))} + /> + )} + + + + ); +} \ No newline at end of file diff --git a/frontend/src/components/AnalyticsView.tsx b/frontend/src/components/AnalyticsView.tsx new file mode 100644 index 0000000..5099ad7 --- /dev/null +++ b/frontend/src/components/AnalyticsView.tsx @@ -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 = ({ 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 ( + + + Бизнес-аналитика на {format(new Date(analytics.date), 'dd MMMM yyyy', { locale: ru })} + + + + + Краткая сводка новостей: + + + {formattedSummary} + + + + + + + Выявленные бизнес-проблемы и решения: + + + {problems.map((item, index) => ( + + } + sx={{ + backgroundColor: 'secondary.50', + '&:hover': { backgroundColor: 'secondary.100' } + }} + > + + Проблема №{index + 1}: {item.problem} + + + + + Решение: + + + {item.solution} + + + + ))} + + ); +}; + +export default AnalyticsView; \ No newline at end of file diff --git a/frontend/src/components/ChartComponent.tsx b/frontend/src/components/ChartComponent.tsx new file mode 100644 index 0000000..ebbfd70 --- /dev/null +++ b/frontend/src/components/ChartComponent.tsx @@ -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 = ({ 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 ( + + + {title} + + + + + Цена: {lastPrice.toFixed(2)} + + = 0 ? 'success.main' : 'error.main', + fontWeight: 'bold' + }} + > + {priceChange >= 0 ? '+' : ''}{priceChange.toFixed(2)} ({priceChangePercent.toFixed(2)}%) + + + + + + + + ); +}; + +export default ChartComponent; \ No newline at end of file diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx new file mode 100644 index 0000000..9105947 --- /dev/null +++ b/frontend/src/components/Header.tsx @@ -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 ( + + + + router.push('/')} + sx={{ + mr: 2, + display: { xs: 'none', md: 'flex' }, + fontWeight: 700, + cursor: 'pointer' + }} + > + ФИНАНСОВО-АНАЛИТИЧЕСКАЯ ПЛАТФОРМА + + + + + + + + + + + + ); +}; + +export default Header; \ No newline at end of file diff --git a/frontend/src/components/NewsList.tsx b/frontend/src/components/NewsList.tsx new file mode 100644 index 0000000..5ea93d6 --- /dev/null +++ b/frontend/src/components/NewsList.tsx @@ -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 = ({ news, topics = [] }) => { + const [selectedTopic, setSelectedTopic] = useState('all'); + + const handleTopicChange = (event: SelectChangeEvent) => { + setSelectedTopic(event.target.value); + }; + + // Фильтруем новости по выбранной теме + const filteredNews = selectedTopic === 'all' + ? news + : news.filter(item => item.topic === selectedTopic); + + return ( + + + + Последние новости + + + {topics.length > 0 && ( + + Тема + + + )} + + + {filteredNews.length === 0 ? ( + + Новости не найдены + + ) : ( + + {filteredNews.map((item, index) => ( + + {index > 0 && } + + + + {item.title} + + + + } + secondary={ + <> + + {format(new Date(item.date), 'dd MMMM yyyy', { locale: ru })} + + + + }> + Подробнее... + + + + {item.content} + + + + + } + /> + + + ))} + + )} + + ); +}; + +export default NewsList; \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..48bf65b --- /dev/null +++ b/frontend/src/lib/api.ts @@ -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 => { + const response = await api.get('/sectors'); + return response.data; +}; + +// Получение данных по сектору +export const getSectorData = async (sector: string, period: number = 30): Promise => { + const response = await api.get(`/sector/${sector}`, { + params: { period }, + }); + return response.data; +}; + +// Получение списка тикеров +export const getTickers = async (sector?: string): Promise => { + const response = await api.get('/tickers', { + params: { sector }, + }); + return response.data; +}; + +// Получение данных по тикеру +export const getTickerData = async (ticker: string, period: number = 30): Promise => { + const response = await api.get(`/ticker/${ticker}`, { + params: { period }, + }); + return response.data; +}; + +// Получение тем новостей +export const getNewsTopics = async (): Promise => { + const response = await api.get('/news/topics'); + return response.data; +}; + +// Получение новостей +export const getNews = async (days: number = 7, topic?: string): Promise => { + const response = await api.get('/news', { + params: { days, topic }, + }); + return response.data; +}; + +// Получение последнего анализа +export const getLatestAnalytics = async (): Promise => { + const response = await api.get('/analytics/latest'); + return response.data; +}; + +// Получение анализа по дате +export const getAnalyticsByDate = async (date: string): Promise => { + const response = await api.get(`/analytics/${date}`); + return response.data; +}; + +// Получение данных для дашборда +export const getDashboardData = async (): Promise => { + const response = await api.get('/dashboard'); + return response.data; +}; + +export default api; \ No newline at end of file diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..b141da2 --- /dev/null +++ b/frontend/tailwind.config.js @@ -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: [], +} \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..0666271 --- /dev/null +++ b/models.py @@ -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 [] \ No newline at end of file diff --git a/news_parser.py b/news_parser.py new file mode 100644 index 0000000..4cae7a0 --- /dev/null +++ b/news_parser.py @@ -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()) diff --git a/open_router.ipynb b/open_router.ipynb index 5a29966..263154b 100644 --- a/open_router.ipynb +++ b/open_router.ipynb @@ -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" ] }, diff --git a/requirements.txt b/requirements.txt index e9264b9..61e1c08 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,15 @@ -pandas -plotly -requests -apimoex -aiohttp -requests -httpx -pybit -aiohttp -altair -pymc -scikit-learn -numpy -seaborn -statsmodels -xgboost -lightgbm -groq -yfinance \ No newline at end of file +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 \ No newline at end of file