/
dimaWebexplorer
/
investmentAssistant
Обзор
Документация
Войти
/
dimaWebexplorer
/
investmentAssistant
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
llmcode
backend/module.py
742 строки
41 KB
Polyakova Alena
LLM search stocks
04 дек 2025, 00:31
04 дек 2025, 00:31
741dd7d
Код
Авторство
О чём код?
import requests import pandas as pd import random import tavily from transformers import AutoTokenizer, AutoModelForSequenceClassification import torch import os import json import time ticker_to_industry_hypothetical = { # Энергетические и минеральные ресурсы 'BANE': 'Энергетические и минеральные ресурсы', 'GAZP': 'Энергетические и минеральные ресурсы', 'BLNG': 'Энергетические и минеральные ресурсы', 'CHGZ': 'Энергетические и минеральные ресурсы', 'JNOS': 'Энергетические и минеральные ресурсы', 'KRKN': 'Энергетические и минеральные ресурсы', 'KZRU': 'Энергетические и минеральные ресурсы', 'MFGS': 'Энергетические и минеральные ресурсы', 'LKOH': 'Энергетические и минеральные ресурсы', 'NVTK': 'Энергетические и минеральные ресурсы', 'OBNE': 'Энергетические и минеральные ресурсы', 'RASP': 'Энергетические и минеральные ресурсы', 'RNFT': 'Энергетические и минеральные ресурсы', 'ROSN': 'Энергетические и минеральные ресурсы', 'SIBN': 'Энергетические и минеральные ресурсы', 'SLAV': 'Энергетические и минеральные ресурсы', 'SNGS': 'Энергетические и минеральные ресурсы', 'SROG': 'Энергетические и минеральные ресурсы', 'TATN': 'Энергетические и минеральные ресурсы', 'TNYA': 'Энергетические и минеральные ресурсы', 'UDMN': 'Энергетические и минеральные ресурсы', 'UKUZ': 'Энергетические и минеральные ресурсы', 'VJGZ': 'Энергетические и минеральные ресурсы', # Финансы 'SBER': 'Финансы', 'VTBR': 'Финансы', 'MOEX': 'Финансы', 'CBOM': 'Финансы', 'BSPB': 'Финансы', 'RGSS': 'Финансы', 'POSI': 'Финансы', 'LSRG': 'Финансы', 'SFIN': 'Финансы', 'RENI': 'Финансы', 'AVAN': 'Финансы', 'SMLT': 'Финансы', 'KFBA': 'Финансы', 'MBNK': 'Финансы', 'USBN': 'Финансы', 'SPBE': 'Финансы', 'WTCM': 'Финансы', 'PRMB': 'Финансы', 'RDRB': 'Финансы', 'ARSA': 'Финансы', 'KUZB': 'Финансы', 'T': 'Финансы', # Несырьевые полезные ископаемые 'ALRS': 'Несырьевые полезные ископаемые', 'BMKK': 'Несырьевые полезные ископаемые', 'BOGN': 'Несырьевые полезные ископаемые', 'BRZL': 'Несырьевые полезные ископаемые', 'CHMF': 'Несырьевые полезные ископаемые', 'CHMK': 'Несырьевые полезные ископаемые', 'ENPG': 'Несырьевые полезные ископаемые', 'GMKN': 'Несырьевые полезные ископаемые', 'IGST': 'Несырьевые полезные ископаемые', 'KMTZ': 'Несырьевые полезные ископаемые', 'KOGK': 'Несырьевые полезные ископаемые', 'LNZL': 'Несырьевые полезные ископаемые', 'MAGN': 'Несырьевые полезные ископаемые', 'MTLR': 'Несырьевые полезные ископаемые', 'NLMK': 'Несырьевые полезные ископаемые', 'PLZL': 'Несырьевые полезные ископаемые', 'ROLO': 'Несырьевые полезные ископаемые', 'RUAL': 'Несырьевые полезные ископаемые', 'SELG': 'Несырьевые полезные ископаемые', 'SGMA': 'Несырьевые полезные ископаемые', 'SNTZ': 'Несырьевые полезные ископаемые', 'SUMZ': 'Несырьевые полезные ископаемые', 'SVTZ': 'Несырьевые полезные ископаемые', 'TRMK': 'Несырьевые полезные ископаемые', 'UELM': 'Несырьевые полезные ископаемые', 'UNKL': 'Несырьевые полезные ископаемые', 'URKZ': 'Несырьевые полезные ископаемые', 'VSMO': 'Несырьевые полезные ископаемые', # Обрабатывающая промышленность 'AFMC': 'Обрабатывающая промышленность', 'AKRN': 'Обрабатывающая промышленность', 'AQUA': 'Обрабатывающая промышленность', 'DGBZ': 'Обрабатывающая промышленность', 'GCHE': 'Обрабатывающая промышленность', 'KAZT': 'Обрабатывающая промышленность', 'KMTX': 'Обрабатывающая промышленность', 'KRVA': 'Обрабатывающая промышленность', 'NKHC': 'Обрабатывающая промышленность', 'PHOR': 'Обрабатывающая промышленность', 'SGZH': 'Обрабатывающая промышленность', 'URAG': 'Обрабатывающая промышленность', 'VLHZ': 'Обрабатывающая промышленность', # Коммунальные услуги 'ASSB': 'Коммунальные услуги', 'BGES': 'Коммунальные услуги', 'CENH': 'Коммунальные услуги', 'DVEC': 'Коммунальные услуги', 'ELFV': 'Коммунальные услуги', 'FEES': 'Коммунальные услуги', 'GSRB': 'Коммунальные услуги', 'HYDR': 'Коммерческие услуги', 'IRAO': 'Коммунальные услуги', 'IVOG': 'Коммунальные услуги', 'KBSB': 'Коммунальные услуги', 'KCHE': 'Коммунальные услуги', 'KESK': 'Коммунальные услуги', 'KLSB': 'Коммунальные услуги', 'KRSB': 'Коммунальные услуги', 'KTSB': 'Коммунальные услуги', 'KZSB': 'Коммерческие услуги', 'LEOG': 'Коммунальные услуги', 'LPOG': 'Коммунальные услуги', 'LPSB': 'Коммунальные услуги', 'LSNG': 'Коммунальные услуги', 'MAGE': 'Коммунальные услуги', 'MISB': 'Коммунальные услуги', 'MOEK': 'Коммунальные услуги', 'MRKC': 'Коммунальные услуги', 'MRKK': 'Коммерческие услуги', 'MRKP': 'Коммунальные услуги', 'MRKS': 'Коммунальные услуги', 'MRKU': 'Коммунальные услуги', 'MRKV': 'Коммунальные услуги', 'MRKY': 'Коммунальные услуги', 'MRKZ': 'Коммунальные услуги', 'MRSB': 'Коммунальные услуги', 'MSNG': 'Коммунальные услуги', 'MSRS': 'Коммерческие услуги', 'NNSB': 'Коммунальные услуги', 'OGKB': 'Коммунальные услуги', 'OMOG': 'Коммунальные услуги', 'PMSB': 'Коммунальные услуги', 'RTGZ': 'Коммунальные услуги', 'RTSB': 'Коммунальные услуги', 'RZOG': 'Коммунальные услуги', 'RZSB': 'Коммунальные услуги', 'SAGO': 'Коммерческие услуги', 'SARE': 'Коммунальные услуги', 'SBEN': 'Коммунальные услуги', 'SGAZ': 'Коммунальные услуги', 'SLEN': 'Коммунальные услуги', 'SMOG': 'Коммунальные услуги', 'STKG': 'Коммунальные услуги', 'STSB': 'Коммунальные услуги', 'TASB': 'Коммунальные услуги', 'TGKA': 'Коммерческие услуги', 'TGKB': 'Коммунальные услуги', 'TGKJ': 'Коммунальные услуги', 'TGKN': 'Коммунальные услуги', 'TNSE': 'Коммунальные услуги', 'TORS': 'Коммунальные услуги', 'TUOG': 'Коммунальные услуги', 'TVEO': 'Коммунальные услуги', 'ULEN': 'Коммунальные услуги', 'UPRO': 'Коммерческие услуги', 'VGSB': 'Коммунальные услуги', 'VLOG': 'Коммунальные услуги', 'VRSB': 'Коммунальные услуги', 'VTGK': 'Коммунальные услуги', 'YAKG': 'Коммунальные услуги', 'YKEN': 'Коммунальные услуги', 'YRSB': 'Коммунальные услуги', # Связь 'AFKS': 'Связь', 'CNTL': 'Связь', 'MGTS': 'Связь', 'MTSS': 'Связь', 'NSVZ': 'Связь', 'RTKM': 'Связь', 'TTLK': 'Связь', 'VEON-RX': 'Связь', # Транспорт 'AFLT': 'Транспорт', 'FESH': 'Транспорт', 'FLOT': 'Транспорт', 'GTRK': 'Транспорт', 'MTPV': 'Транспорт', 'NKHP': 'Транспорт', 'NMTP': 'Транспорт', 'NOMP': 'Транспорт', 'PRIM': 'Транспорт', 'SEMP': 'Транспорт', 'TUZA': 'Транспорт', 'URAL': 'Транспорт', 'UTAR': 'Транспорт', 'VFLT': 'Транспорт', 'VMTP': 'Транспорт', # Розничная торговля 'APTK': 'Розничная торговля', 'LENT': 'Розничная торговля', 'MGNT': 'Розничная торговля', 'OKEY': 'Розничная торговля', # Потребительские товары длительного пользования -> Промышленность 'DZRD': 'Потребительские товары длительного пользования', 'NFAZ': 'Потребительские товары длительного пользования', 'PAZA': 'ТрансПотребительские товары длительного пользованияпорт', 'PIKK': 'Потребительские товары длительного пользования', 'SVAV': 'Потребительские товары длительного пользования', 'ZILL': 'Потребительские товары длительного пользования', # Промышленное производство 'AGRT': 'Промышленное производство', 'CHKZ': 'Промышленное производство', 'ELST': 'Промышленное производство', 'GAZA': 'Промышленное производство', 'KMAZ': 'Промышленное производство', 'KMEZ': 'Промышленное производство', 'KZOS': 'Промышленное производство', 'MMMZ': 'Промышленное производство', 'NKSH': 'Промышленное производство', 'PRFN': 'Промышленное производство', 'RTLM': 'Промышленное производство', 'UWGN': 'Промышленное производство', 'VDOK': 'Промышленное производство', 'VSYD': 'Промышленное производство', 'ZVEZ': 'Промышленное производство', # Технологии 'RBCM': 'Технологии', 'VKCO': 'Технологии', # Здравоохранение 'DIOD': 'Здравоохранение', 'GEMA': 'Здравоохранение', 'KSIN': 'Здравоохранение', 'LIFE': 'Здравоохранение', 'ORGN': 'Здравоохранение', # Производственно-технические услуги 'DORS': 'Производственно-технические услуги', 'GZES': 'Производственно-технические услуги', 'KLOG': 'Производственно-технические услуги', 'MSTT': 'Производственно-технические услуги', 'TOGZ': 'Производственно-технические услуги', 'VOAD': 'Производственно-технические услуги', 'VOGZ': 'Производственно-технические услуги', 'YROG': 'Производственно-технические услуги', # Дистрибуция 'KUNP': 'Дистрибуция', 'MVID': 'Дистрибуция', 'NOLE': 'Дистрибуция', 'SARN': 'Дистрибуция', # Потребительские услуги 'ROST': 'Потребительские услуги', # Коммерческие услуги 'GRNT': 'Коммерческие услуги', 'QIWI': 'Коммерческие услуги', 'SVET': 'Коммерческие услуги' } def assign_industry(row): ticker = row['Тикер'] # Проверяем, начинается ли тикер на 'SU' if ticker.startswith('SU'): return 'Государственные' # Ищем отрасль в словаре industry = ticker_to_industry_hypothetical.get(ticker) # Если не найдено, возвращаем 'Прочее' return industry if industry is not None else 'Прочее' def parse_moex_stocks(boards=['TQBR', 'TQTF']): base_url = "https://iss.moex.com/iss/engines/stock/markets/shares/boards/{board}/securities.json" all_data = [] column_translation = { 'SECID': 'Тикер', 'SHORTNAME': 'Краткое название', 'SECNAME': 'Полное название', 'LAST': 'Цена', 'PREVLEGALCLOSEPRICE': 'Цена закрытия' } for board in boards: try: url = base_url.format(board=board) response = requests.get(url) data = response.json() df_securities = pd.DataFrame(data['securities']['data'], columns=data['securities']['columns']) df_marketdata = pd.DataFrame(data['marketdata']['data'], columns=data['marketdata']['columns']) df_combined = pd.merge(df_securities, df_marketdata, on='SECID', suffixes=('', '_market')) df_combined['BOARD'] = board all_data.append(df_combined) except requests.exceptions.RequestException as e: continue if not all_data: return None frames = [] for df in all_data: if df is None: continue # пропускаем не-DataFrame if not isinstance(df, pd.DataFrame): continue # пропускаем полностью пустые фреймы if df.empty: continue # выкидываем колонки, где все значения NaN df_clean = df.dropna(axis=1, how='all') # если после чистки колонок ничего не осталось — пропускаем if df_clean.empty or df_clean.shape[1] == 0: continue frames.append(df_clean) if frames: df_final = pd.concat(frames, ignore_index=True) else: # ничего адекватного не осталось — возвращаем пустой DataFrame df_final = pd.DataFrame() df_final = df_final.rename(columns=column_translation) # Сразу отбираем только нужные колонки df_final = df_final[['Тикер', 'Цена закрытия']] return df_final def parse_moex_bonds(): """ Парсит данные об облигациях с MOEX ISS API. Returns: pd.DataFrame: DataFrame с данными. """ url = "https://iss.moex.com/iss/engines/stock/markets/bonds/boards/TQOB/securities.json" try: response = requests.get(url) response.raise_for_status() data = response.json() df_securities = pd.DataFrame(data['securities']['data'], columns=data['securities']['columns']) df_marketdata = pd.DataFrame(data['marketdata']['data'], columns=data['marketdata']['columns']) df = pd.merge(df_securities, df_marketdata, on='SECID') translation_dict = { 'SECID': 'Тикер', 'SHORTNAME': 'Краткое название', 'SECNAME': 'Полное название', 'LAST': 'Цена', 'YIELD': 'Доходность к погашению', 'MODDURATION': 'Модифицированная дюрация', 'COUPONPERCENT': 'Купон (%)', 'LOTVALUE': 'Номинал', 'ACCRUEDINT': 'НКД', 'COUPONPERIOD': 'Период купона', 'NEXTCOUPON': 'Следующий купон', 'MATDATE': 'Дата погашения' } existing_columns = {} for eng, rus in translation_dict.items(): if eng in df.columns: existing_columns[eng] = rus df = df.rename(columns=existing_columns) result_columns = list(existing_columns.values()) numeric_cols = ['Цена', 'Доходность к погашению', 'НКД'] for col in numeric_cols: if col in df.columns: df[col] = pd.to_numeric(df[col], errors='coerce') df['Цена закрытия'] = df['Цена']/100 * df['Номинал'] df = df[['Тикер', 'Цена закрытия', 'Доходность к погашению']] return df except Exception as e: print(f"Ошибка при получении данных с MOEX ISS: {e}") return None OPENROUTER_MODEL_ID = "kwaipilot/kat-coder-pro:free" def _call_kwaipilot_llm(prompt: str, max_retries: int = 3, timeout: int = 120) -> str | None: """ Вызов kwaipilot/kat-coder-pro:free через OpenRouter. Ключ берётся из переменной окружения OPENROUTER_API_KEY. """ api_key = os.getenv("OPENROUTER_API_KEY") if not api_key: print("OPENROUTER_API_KEY не задан в окружении.") return None url = "https://openrouter.ai/api/v1/chat/completions" headers = { "Authorization": f"Bearer {api_key}", "HTTP-Referer": "http://localhost", "X-Title": "InvestmentAssistant LLM stocks selector", } payload = { "model": OPENROUTER_MODEL_ID, "messages": [ { "role": "system", "content": ( "Ты финансовый аналитик по российскому рынку. " "Отвечай СТРОГО в формате JSON, который запрашивает пользователь." ), }, {"role": "user", "content": prompt}, ], "temperature": 0.1, } last_error = None for attempt in range(max_retries): try: resp = requests.post(url, headers=headers, json=payload, timeout=timeout) resp.raise_for_status() data = resp.json() return data["choices"][0]["message"]["content"].strip() except Exception as e: last_error = e print(f"Ошибка вызова LLM (попытка {attempt + 1}/{max_retries}): {e}") if attempt < max_retries - 1: time.sleep(2 ** attempt) print(f"Не удалось получить ответ от модели {OPENROUTER_MODEL_ID}: {last_error}") return None def _extract_json_from_llm_text(text: str): """ Аккуратно вырезаем JSON из ответа модели. """ text = (text or "").strip() if not text: return None if text[0] == "{" and text[-1] == "}": try: return json.loads(text) except json.JSONDecodeError: pass start = text.find("{") end = text.rfind("}") if start != -1 and end != -1 and end > start: candidate = text[start:end + 1] try: return json.loads(candidate) except json.JSONDecodeError: return None return None def get_stocks_above_yield(target_yield, df_stocks): """ Новая версия: - Берёт universe акций из df_stocks (Тикер, Цена закрытия, Отрасль) - Строит промпт с target_yield - Вызывает kwaipilot/kat-coder-pro:free через OpenRouter - Собирает DataFrame с колонками: 'Тикер', 'Цена закрытия', 'Дивидендная доходность' Если LLM не сработала — fallback на старую рандомную логику. """ import pandas as _pd # чтобы не городить глобальные alias, если вдруг что # Нет данных — сразу пусто if df_stocks is None or df_stocks.empty: print("Не удалось получить данные об акциях.") return _pd.DataFrame() # Проверяем базовые колонки, без них create_portfolio всё равно не взлетит required_cols = ["Тикер", "Цена закрытия"] for c in required_cols: if c not in df_stocks.columns: print(f"В df_stocks нет ожидаемой колонки '{c}'. Возвращаю fallback-результат.") return _fallback_stocks_by_yield(target_yield, df_stocks) # Подготовка таблицы для промпта: добавляем отрасли try: df_for_prompt = add_industry_column(df_stocks[["Тикер", "Цена закрытия"]].copy()) except Exception as e: print(f"Не удалось добавить отрасли для промпта: {e}. Использую 'Прочее'.") df_for_prompt = df_stocks[["Тикер", "Цена закрытия"]].copy() df_for_prompt["Отрасль"] = "Прочее" # Ограничиваем размер universe для промпта max_candidates = 60 if len(df_for_prompt) > max_candidates: df_for_prompt = df_for_prompt.sample(max_candidates, random_state=42) df_prompt = df_for_prompt.rename( columns={"Тикер": "Ticker", "Отрасль": "Sector", "Цена закрытия": "Price"} ) csv_str = df_prompt.to_csv(index=False, sep=";") # --- ПРОМПТ ДЛЯ МОДЕЛИ --- prompt = f""" У тебя есть список российских акций в CSV-формате (разделитель ';'): {csv_str} Колонки: - Ticker — тикер - Sector — отрасль - Price — текущая цена акции в рублях Требуется выбрать те акции, по которым ожидаемая ДИВИДЕНДНАЯ доходность не ниже {target_yield:.2f}% годовых. Если ты не уверен в точном значении, используй своё лучшее приближение исходя из общих представлений о компании, секторе и типичных дивидендных политик. Верни СТРОГО валидный JSON без любого текста до или после него. Формат ответа: {{ "stocks": [ {{ "ticker": "GAZP", "dividend_yield_pct": 12.5 }}, {{ "ticker": "LKOH", "dividend_yield_pct": 10.1 }} ] }} Требования: - Используй ТОЛЬКО тикеры из таблицы. - Включай только те акции, у которых твоя оценка dividend_yield_pct >= {target_yield:.2f}. - Не добавляй никаких других полей и комментариев. """ # --- ВЫЗОВ LLM --- raw = _call_kwaipilot_llm(prompt) if raw is None: print("LLM не ответила, использую fallback-логику по акциям.") return _fallback_stocks_by_yield(target_yield, df_stocks) parsed = _extract_json_from_llm_text(raw) if not isinstance(parsed, dict): print("Ответ LLM не является JSON-объектом, fallback.") return _fallback_stocks_by_yield(target_yield, df_stocks) stocks = parsed.get("stocks") if not isinstance(stocks, list) or not stocks: print("В ответе LLM нет списка 'stocks', fallback.") return _fallback_stocks_by_yield(target_yield, df_stocks) # Собираем карту тикер -> доходность yield_map = {} for item in stocks: try: t = str(item.get("ticker")).strip() y = float(item.get("dividend_yield_pct")) except Exception: continue if not t: continue yield_map[t] = y if not yield_map: print("LLM не вернула ни одной валидной акции с доходностью, fallback.") return _fallback_stocks_by_yield(target_yield, df_stocks) # Маппим доходности в df_stocks df = df_stocks.copy() df["Дивидендная доходность"] = df["Тикер"].map(yield_map) # Выкидываем тикеры, которых нет в ответе LLM df = df.dropna(subset=["Дивидендная доходность"]) if df.empty: print("После маппинга LLM-доходностей не осталось акций, fallback.") return _fallback_stocks_by_yield(target_yield, df_stocks) # Фильтрация по целевой доходности df_filtered = df[df["Дивидендная доходность"] >= target_yield].copy() if df_filtered.empty: print(f"LLM не предложила акций с дивидендной доходностью >= {target_yield}%. " f"Возвращаю просто акции с ненулевой оценкой LLM.") df_filtered = df.copy() # Сортировка по убыванию доходности df_filtered.sort_values(by="Дивидендная доходность", ascending=False, inplace=True) return df_filtered def _fallback_stocks_by_yield(target_yield, df_stocks): """ Старый метод: если LLM не сработала, генерируем 'Дивидендная доходность' и фильтруем как раньше. """ import pandas as _pd if df_stocks is None or df_stocks.empty: print("Не удалось получить данные об акциях (fallback).") return _pd.DataFrame() # Если уже есть колонка доходности — используем её if "Дивидендная доходность" in df_stocks.columns and not df_stocks["Дивидендная доходность"].isna().all(): df = df_stocks else: # Генерация доходности, как было раньше mu = target_yield + 3 sigma = 2.5 min_yield = target_yield + 0.01 generated_yields = [] for _ in range(len(df_stocks)): val = random.normalvariate(mu, sigma) val = max(val, min_yield) val = min(val, 30.0) generated_yields.append(round(val, 2)) df_stocks = df_stocks.copy() df_stocks["Дивидендная доходность"] = generated_yields df = df_stocks # Фильтрация по доходности df_filtered = df[df["Дивидендная доходность"] >= target_yield].copy() if df_filtered.empty: print(f"Не найдено акций с дивидендной доходностью >= {target_yield}% (fallback).") return _pd.DataFrame() df_filtered.sort_values(by="Дивидендная доходность", ascending=False, inplace=True) return df_filtered def get_bonds_by_yield(target_yield,df_bonds): """ Находит облигации с доходностью выше или равной заданной. В реальности колонка 'Доходность к погашению' может отсутствовать, поэтому генерируется. Args: target_yield (float): Минимальная требуемая доходность. Returns: pd.DataFrame: DataFrame с подходящими облигациями. """ if df_bonds is None or df_bonds.empty: print("Не удалось получить данные об облигациях.") return pd.DataFrame() # Проверяем, есть ли реальная колонка доходности if 'Доходность к погашению' in df_bonds.columns and not df_bonds['Доходность к погашению'].isna().all(): print("Используются реальные данные о доходности облигаций.") df = df_bonds else: print("Колонка 'Доходность к погашению' отсутствует или пуста. Генерируем случайные значения.") # Генерация доходности для облигаций mu = target_yield + 1 # Облигации обычно имеют доходность ближе к целевой sigma = 1.5 min_yield = target_yield generated_yields = [] for _ in range(len(df_bonds)): val = random.normalvariate(mu, sigma) val = max(val, min_yield) val = min(val, 25.0) generated_yields.append(round(val, 2)) df_bonds['Доходность к погашения'] = generated_yields df = df_bonds # Фильтрация по доходности df_filtered = df[df['Доходность к погашению'] >= target_yield].copy() if df_filtered.empty: print(f"Не найдено облигаций с доходностью >= {target_yield}%.") return pd.DataFrame() # Сортировка по убыванию доходности df_filtered.sort_values(by='Доходность к погашению', ascending=False, inplace=True) return df_filtered def calculate_regular_deposit_accumulation(future_value, target_yield, years): regular_deposit = future_value/(((1+target_yield/100)**years-1)/(target_yield/100)) return regular_deposit def calculate_regular_deposit_passive(want_to_get, target_yield, years): regular_deposit = (want_to_get / (target_yield/100))/(((1+target_yield/100)**years-1)/(target_yield/100)) return regular_deposit def create_portfolio(regular_deposit, result_df_stocks,result_df_bonds, stock_bond_ratio=0.6): """ Создает портфель из 5 бумаг с заданным соотношением акции/облигации """ # Рассчитываем суммы для акций и облигаций stock_amount = regular_deposit * stock_bond_ratio bond_amount = regular_deposit * (1 - stock_bond_ratio) # Выбираем топ-3 акции по дивидендной доходности top_stocks = result_df_stocks.nlargest(3, 'Дивидендная доходность') # Выбираем топ-2 облигации по доходности к погашению top_bonds = result_df_bonds.nlargest(2, 'Доходность к погашению') portfolio = [] # Распределяем акции stock_per_instrument = stock_amount / len(top_stocks) for _, stock in top_stocks.iterrows(): quantity = int(stock_per_instrument / stock['Цена закрытия']) # Целое число portfolio.append({ 'ticker': stock['Тикер'], 'type': 'Акция', 'price': stock['Цена закрытия'], 'quantity': quantity, 'summ': quantity * stock['Цена закрытия'], 'yield': stock['Дивидендная доходность'] }) # Распределяем облигации bond_per_instrument = bond_amount / len(top_bonds) for _, bond in top_bonds.iterrows(): quantity = int(bond_per_instrument / bond['Цена закрытия']) # Целое число portfolio.append({ 'ticker': bond['Тикер'], 'type': 'Облигация', 'price': bond['Цена закрытия'], 'quantity': quantity, 'summ': quantity * bond['Цена закрытия'], 'yield': bond['Доходность к погашению'] }) portfolio_df = pd.DataFrame(portfolio) total_value = portfolio_df['summ'].sum() print(f"Общая сумма портфеля: {total_value:.2f}") print(f"Целевая сумма: {regular_deposit:.2f}") print(f"Отклонение: {abs(total_value - regular_deposit):.2f}") return portfolio_df def add_industry_column(df_with_tickers): """ Принимает DataFrame с колонкой 'Тикер' и добавляет колонку 'Отрасль'. Использует гипотетический словарь и правила для облигаций SU. """ df_with_tickers = df_with_tickers.copy() df_with_tickers['Отрасль'] = df_with_tickers.apply(assign_industry, axis=1) return df_with_tickers def get_news_sentiment_for_tickers(df_with_industries, tavily_client, finbert_tokenizer, finbert_model): """ Получает новости и анализирует их с помощью Tavily и FinBert. Вход: DataFrame с 'Тикер', 'Отрасль'. Вход: tavily_client (объект tavily.TavilyClient), finbert_tokenizer, finbert_model (объекты transformers). Возвращает DataFrame с 'Тикер', 'Отрасль', 'Новостной фон (отрасль)'. """ results = [] sector_sentiment_cache = {} for index, row in df_with_industries.iterrows(): ticker = row['Тикер'] sector = row['Отрасль'] sector_news_content = "" sector_sentiment_overall = "Нейтральный" if pd.notna(sector) and sector not in ['Государственные', 'Прочее']: if sector in sector_sentiment_cache: sector_sentiment_overall = sector_sentiment_cache[sector] print(f"Используем кэшированный результат для сектора {sector}") else: try: sector_news_result = tavily_client.search(f"новости {sector} рынок акции", max_results=3) sector_news_content = " ".join([item.get('content', '') for item in sector_news_result.get('results', [])]) except Exception as e: print(f"Ошибка получения новостей по сектору {sector} для тикера {ticker}: {e}") sector_news_content = "" if sector_news_content.strip(): try: inputs = finbert_tokenizer(sector_news_content, return_tensors="pt", truncation=True, padding=True, max_length=512) with torch.no_grad(): outputs = finbert_model(**inputs) predictions = torch.nn.functional.softmax(outputs.logits, dim=-1) predicted_class_idx = torch.argmax(predictions, dim=1).item() label_map = {0: "Негативный", 1: "Нейтральный", 2: "Позитивный"} sector_sentiment_overall = label_map.get(predicted_class_idx, "Неопределённый") except Exception as e: print(f"Ошибка анализа тональности новостей по сектору {sector} для тикера {ticker}: {e}") sector_sentiment_overall = "Неопределённый" sector_sentiment_cache[sector] = sector_sentiment_overall elif sector in ['Государственные', 'Прочее']: sector_sentiment_overall = "Нет данных по сектору" results.append({ 'Тикер': ticker, 'Отрасль': sector, 'Новостной фон (отрасль)': sector_sentiment_overall, }) return pd.DataFrame(results) def post_news_sentiment_endpoint(first_method_output_df_or_list, tavily_api_key_str, finbert_model_name_or_path): import pandas as pd if isinstance(first_method_output_df_or_list, list): if first_method_output_df_or_list and 'ticker' in first_method_output_df_or_list[0]: tickers_from_list = [item['ticker'] for item in first_method_output_df_or_list] df_input = pd.DataFrame({'Тикер': tickers_from_list}) else: print("Ошибка: Список не содержит элементов с ключом 'ticker'.") error_df = pd.DataFrame(columns=['Тикер', 'Отрасль', 'Новостной фон (отрасль)']) return error_df elif isinstance(first_method_output_df_or_list, pd.DataFrame): df_input = first_method_output_df_or_list else: print(f"Ошибка: Неподдерживаемый тип данных: {type(first_method_output_df_or_list)}") error_df = pd.DataFrame(columns=['Тикер', 'Отрасль', 'Новостной фон (отрасль)']) return error_df # 1. Инициализация Tavily клиента tavily_client = tavily.TavilyClient(api_key=tavily_api_key_str) # 2. Загрузка FinBERT токенизатора и модели try: finbert_tokenizer = AutoTokenizer.from_pretrained(finbert_model_name_or_path) finbert_model = AutoModelForSequenceClassification.from_pretrained(finbert_model_name_or_path) finbert_model.eval() except Exception as e: print(f"Ошибка загрузки FinBERT модели '{finbert_model_name_or_path}': {e}") error_df = df_input[['Тикер']].copy() error_df['Отрасль'] = 'Ошибка загрузки модели' error_df['Новостной фон (отрасль)'] = 'Ошибка' return error_df # 3. Добавляем отрасли к данным из первого метода if 'Тикер' not in df_input.columns: print("Ошибка: DataFrame не содержит колонки 'Тикер'.") error_df = pd.DataFrame(columns=['Тикер', 'Отрасль', 'Новостной фон (отрасль)']) return error_df df_with_industries = add_industry_column(df_input[['Тикер']]) # 4. Получаем новостной фон news_sentiment_df = get_news_sentiment_for_tickers(df_with_industries, tavily_client, finbert_tokenizer, finbert_model) # 5. Возвращаем итоговый DataFrame required_cols = ['Тикер', 'Отрасль', 'Новостной фон (отрасль)'] if not all(col in news_sentiment_df.columns for col in required_cols): print("Ошибка: Результат get_news_sentiment_for_tickers не содержит ожидаемых колонок.") return pd.DataFrame(columns=required_cols) return news_sentiment_df