Блог им. NatashaMe

Программа для загрузки котировок акций с мосбиржи

    • 25 октября 2025, 08:43
    • |
    • XXX★
  • Еще
Я тут слышал у многих проблемы с получением котировок, после того, как Финам там что-то запретил у себя скачивать?
Вобщем я с ИИ посидел часов 10 и мы написали программу. Ну как мы. Я ни строчки не написал, но руководил и поставил себя автором.
Ну оно так и бывает. Вобщем, кому нужно, вот версия 1.0 такого добра:

Программа для загрузки котировок акций с мосбиржи
Программа для загрузки котировок акций с мосбиржи
Программа для загрузки котировок акций с мосбиржи
Программа для загрузки котировок акций с мосбиржи

Документация такая:

MOEX DATA DOWNLOADER — ПОЛНАЯ ДОКУМЕНТАЦИЯ

📋 ОБЩЕЕ ОПИСАНИЕ

MOEX Data Downloader — это специализированное приложение для загрузки, обновления и обработки исторических биржевых данных с Московской биржи (MOEX). Программа предоставляет удобный графический интерфейс для работы с котировками акций.

🚀 АЛГОРИТМ РАБОТЫ ПРИЛОЖЕНИЯ

Основной принцип:
1. Первоначальная загрузка → Регулярное обновление → Генерация таймфреймов (при необходимости)
2. Все таймфреймы (кроме 1 минуты) генерируются из минутных данных
3. Для обновления данных необходимы файлы *_1min.csv (или *_1min.txt)

Детальный процесс:

1. ЗАГРУЗКА ДАННЫХ (вкладка «Загрузка данных»)
— Загружаются 1-минутные данные с MOEX API
— Автоматически создаются выбранные таймфреймы на основе минутных данных
— Сохраняются файлы в формате: ТИКЕР_ТАЙМФРЕЙМ.расширение

2. ОБНОВЛЕНИЕ ДАННЫХ (вкладка «Обновление данных»)
— Сканируются существующие минутные файлы (*_1min.*)
— Определяется последняя доступная дата для каждого тикера
— Загружаются только новые данные с даты, следующей за последней
— Автоматически обновляются все существующие таймфреймы

3. ГЕНЕРАЦИЯ ТАЙМФРЕЙМОВ (ДОПОЛНИТЕЛЬНАЯ ФУНКЦИЯ)
— Необязательная операция — создание недостающих таймфреймов
— Используется, если нужно добавить таймфреймы к уже существующим данным
— Рекомендуется создавать все нужные таймфреймы при первоначальной загрузке

🏗️ АЛГОРИТМ ПРЕОБРАЗОВАНИЯ ТАЙМФРЕЙМОВ

Из 1-минутных данных создаются:

Таймфрейм | Правило формирования
------------|---------------------
5 минут | Группировка по 5 минутным интервалам
15 минут | Группировка по 15 минутным интервалам
1 час | Группировка по часовым интервалам
4 часа | Группировка по 4-часовым интервалам
1 день | Группировка по дневным интервалам

Формирование свечей:
— OPEN — цена открытия первой свечи в интервале
— HIGH — максимальная цена из всех свечей интервала
— LOW — минимальная цена из всех свечей интервала
— CLOSE — цена закрытия последней свечи интервала
— VOLUME — суммарный объем всех свечей интервала

📊 ОПИСАНИЕ ИНТЕРФЕЙСА

ВКЛАДКА «ЗАГРУЗКА ДАННЫХ»

Поля ввода:
— Тикеры — список тикеров через запятую (кнопка "..." для выбора из списка)
*По умолчанию загружаются тикеры из индекса IMOEX через API MOEX:*
*API вызов: iss.moex.com/iss/statistics/engines/stock/markets/index/analytics/IMOEX.json*
*Это обеспечивает актуальный список ликвидных акций*
— Начальная дата — дата начала загрузки в формате ГГГГ-ММ-ДД
— Конечная дата — дата окончания загрузки
— Папка для сохранения — путь для сохранения файлов

Настройки:
— Таймфреймы — выбор временных интервалов для создания
— Формат файлов — CSV (с угловыми скобками) или TXT (без скобок)
— Только будни — исключать выходные дни из загрузки

Кнопки:
— "..." (возле тикеров) — открывает окно выбора тикеров из списка MOEX
— «Обзор» — выбор папки для сохранения
— «СКАЧАТЬ ДАННЫЕ» — запуск процесса загрузки

Индикаторы:
— Общий прогресс — прогресс по всем тикерам и таймфреймам
— Прогресс загрузки дня — прогресс загрузки текущего дня
— Лог выполнения — подробный журнал операций
— Статусная строка — текущее состояние приложения

ВКЛАДКА «ОБНОВЛЕНИЕ ДАННЫХ»

Поля ввода:
— Папка с данными — путь к папке с ранее загруженными данными
— Обновить до даты — дата, до которой обновлять данные

Кнопки:
— "..." — выбор папки с данными
— «Сканировать файлы» — обновление списка тикеров
— «ОБНОВИТЬ» — запуск обновления выбранных тикеров
— «ГЕНЕРИРОВАТЬ» — создание недостающих таймфреймов (дополнительная функция)

Таблица тикеров:
— Выбрать — чекбокс для выбора тикера для обновления
— Тикер — название тикера
— Последняя дата — дата последней доступной свечи
— Кол-во свечей — общее количество свечей в файле
— Таймфреймы — список доступных таймфреймов

Индикаторы:
— Прогресс обновления — общий прогресс обновления
— Лог обновления — журнал операций обновления

🔧 ТЕХНИЧЕСКИЕ ДЕТАЛИ ЗАГРУЗКИ

Почему данные загружаются порциями?
MOEX API имеет ограничение на количество возвращаемых свечей (максимум 500 свечей за один запрос). Поэтому для загрузки полного дня данных применяется алгоритм последовательных запросов:

Пример загрузки данных за один день:
Первый запрос: с 09:30:00 до [время последней свечи + 1 минута]
Второй запрос: с [время последней свечи] до конца дня
и т.д., пока не будут получены все свечи дня

Конкретный API вызов:
url = «iss.moex.com/iss/engines/stock/markets/shares/securities/GAZP/candles.json»
params = {
'from': '2024-01-15 09:30:00',
'till': '2024-01-15 18:45:00',
'interval': 1,
'iss.meta': 'off'
}

Этот подход обеспечивает:
✅ Полноту данных (все минутные свечи за день)
✅ Обход ограничений API
✅ Стабильность загрузки при большом объеме данных

💾 ФОРМАТЫ ФАЙЛОВ

CSV формат (с угловыми скобками):
<DATE>,<TIME>,<OPEN>,<HIGH>,<LOW>,<CLOSE>,<VOL>
20231201,100000,315.5000000,315.8000000,315.2000000,315.6000000,15000

TXT формат (табуляция):
DATE TIME OPEN HIGH LOW CLOSE VOL
20231201 100000 315.5000000 315.8000000 315.2000000 315.6000000 15000

🎯 РЕКОМЕНДАЦИИ ПО ИСПОЛЬЗОВАНИЮ

Для новых пользователей:
1. Начните с вкладки «Загрузка данных»
2. Выберите тикеры через кнопку "..." (рекомендуется использовать список IMOEX)
3. Укажите период загрузки (рекомендуется 30-60 дней для начала)
4. Выберите все нужные таймфреймы сразу
5. Нажмите «СКАЧАТЬ ДАННЫЕ»

Для регулярного использования:
1. Используйте вкладку «Обновление данных»
2. Укажите папку с ранее загруженными данными
3. Нажмите «Сканировать файлы»
4. Выберите тикеры для обновления
5. Укажите дату обновления и нажмите «ОБНОВИТЬ»

При добавлении новых таймфреймов (редкий случай):
1. Перейдите на вкладку «Обновление данных»
2. Нажмите «ГЕНЕРИРОВАТЬ»
3. Выберите нужные таймфреймы
4. Нажмите «Сгенерировать»

⚠️ ВАЖНЫЕ ЗАМЕЧАНИЯ

— Для работы обновления необходимы файлы *_1min.*
— Все таймфреймы (кроме 1min) создаются из минутных данных
— При обновлении данных автоматически обновляются все существующие таймфреймы
— Рекомендуется использовать формат CSV для лучшей совместимости
— При первом использовании создается папка 'out' в директории программы
— Загрузка данных происходит порциями из-за ограничений MOEX API (500 свечей за запрос)

---
Версия программы: 1.0
Автор: t.me/GeorgyBlog
---


Т.е. вкратце суть логики: вы ей даете выкачать дневные свечи и на основе их она генерирует все остальные. До тех пор, пока у вас есть файлы с дневными свечами — вы их можете обновлять, не выкачивая заново полностью и она будет перестраивать файлы с другими свечами с нуля после обновления. Если брать период за 10+ лет, то на один тикер выходит больше 100мб данных и такой подход, возможно, не супер удобный/быстрый, но в целом — рабочий и надежный. Тут есть огромный простор для улучшений под свои нужды. Deepseek вам в помощь — скормите ей скрипт и попросите сделать что нужно.

Несмотря на то что я влепил свое авторство как руководитель процесса — я, как и говорил, ни строчки кода, к счастью моему не написал, все претензии по качеству кода можно смело слать китайцам. Тем не менее, за 10+ часов этой разработки мы с Deepseek более менее вылизали что смогли функционально и явные баги мне неизвестны. Свою работу делает. Проверял исключительно под Linux. Работает это под Windows или нет я не знаю и не сильно интересно, если честно. Как говорили в 1998 — windows must die!

Код на питоне:
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import pandas as pd
import requests
import os
from datetime import datetime, timedelta
import logging
import time
import threading
import glob
import numpy as np

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class MoexDataDownloader:
    def __init__(self, root):
        self.root = root
        # Загружаем тикеры с MOEX при инициализации
        self.imoex_tickers = self.load_initial_tickers()
        self.downloading = False
        self.current_ticker = ""
        self.ticker_vars = {}  # Для хранения чекбоксов тикеров
        self.selected_tickers_state = {}  # Для сохранения состояния выделения тикеров
        
        # Создаем папку out если ее нет
        self.default_out_path = os.path.join(os.getcwd(), 'out')
        os.makedirs(self.default_out_path, exist_ok=True)
        
        self.setup_ui()
        
        # Автоматическое сканирование при открытии вкладки
        self.notebook.bind('<<NotebookTabChanged>>', self.on_tab_changed)

    def load_initial_tickers(self):
        """Загружает начальные тикеры с MOEX"""
        try:
            url = "https://iss.moex.com/iss/statistics/engines/stock/markets/index/analytics/IMOEX.json?iss.meta=off&limit=9999"
            response = requests.get(url, timeout=10)
            if response.status_code == 200:
                data = response.json()
                tickers_data = data['analytics']['data']
                # Берем только тикеры из данных
                tickers = list(set([ticker_info[2] for ticker_info in tickers_data]))
                tickers.sort()
                return tickers
            else:
                # Если не удалось загрузить, используем дефолтный список
                return ['SBER', 'GAZP', 'LKOH', 'GMKN', 'ROSN', 'NVTK', 'TATN', 'MTSS', 
                       'MGNT', 'SNGS', 'SNGSP', 'CHMF', 'PLZL', 'ALRS', 'MOEX', 'VTBR',
                       'PHOR', 'RUAL', 'AFKS', 'PIKK', 'TCSG', 'POLY', 'YNDX', 'OZON']
        except Exception as e:
            print(f"Ошибка загрузки тикеров: {e}")
            return ['SBER', 'GAZP', 'LKOH', 'GMKN', 'ROSN', 'NVTK', 'TATN', 'MTSS', 
                   'MGNT', 'SNGS', 'SNGSP', 'CHMF', 'PLZL', 'ALRS', 'MOEX', 'VTBR',
                   'PHOR', 'RUAL', 'AFKS', 'PIKK', 'TCSG', 'POLY', 'YNDX', 'OZON']

    def on_tab_changed(self, event):
        """Автоматическое сканирование при открытии вкладки обновления"""
        current_tab = self.notebook.tab(self.notebook.select(), "text")
        if current_tab == "Обновление данных":
            # Небольшая задержка чтобы интерфейс успел отобразиться
            self.root.after(100, self.scan_files)

    def setup_ui(self):
        self.root.title("MOEX Data Downloader - Скачивание данных Московской биржи")
        self.root.geometry("1600x1200")
        
        # Центрируем окно на экране
        self.root.update_idletasks()
        x = (self.root.winfo_screenwidth() - 1600) // 2
        y = (self.root.winfo_screenheight() - 1200) // 2
        self.root.geometry(f"1600x1200+{x}+{y}")

        # Создаем Notebook (вкладки)
        self.notebook = ttk.Notebook(self.root)
        self.notebook.pack(fill='both', expand=True, padx=10, pady=10)

        # Вкладка 1: Загрузка данных
        self.download_tab = ttk.Frame(self.notebook)
        self.notebook.add(self.download_tab, text='Загрузка данных')

        # Вкладка 2: Обновление данных
        self.update_tab = ttk.Frame(self.notebook)
        self.notebook.add(self.update_tab, text='Обновление данных')

        # Вкладка 3: Документация
        self.docs_tab = ttk.Frame(self.notebook)
        self.notebook.add(self.docs_tab, text='Документация')

        # Настраиваем вкладки
        self.setup_download_tab()
        self.setup_update_tab()
        self.setup_docs_tab()

    def setup_download_tab(self):
        """Настройка вкладки загрузки данных"""
        # Автоматические даты
        end_date = datetime.now().strftime('%Y-%m-%d')
        start_date = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d')
        
        # Основной фрейм для вкладки загрузки
        main_frame = ttk.Frame(self.download_tab, padding="20")
        main_frame.pack(fill='both', expand=True)
        
        # Настройка весов для растягивания
        main_frame.columnconfigure(1, weight=1)
        main_frame.rowconfigure(11, weight=1)

        # Поля ввода - исправленная версия с правильной компоновкой
        ttk.Label(main_frame, text="Тикеры (через запятую):", font=('Arial', 12)).grid(row=0, column=0, sticky='w', padx=10, pady=10)
        
        ticker_input_frame = ttk.Frame(main_frame)
        ticker_input_frame.grid(row=0, column=1, columnspan=2, sticky='we', padx=10, pady=10)
        ticker_input_frame.columnconfigure(0, weight=1)
        
        self.tickers_entry = ttk.Entry(ticker_input_frame, font=('Arial', 11))
        self.tickers_entry.insert(0, ", ".join(self.imoex_tickers))
        self.tickers_entry.grid(row=0, column=0, sticky='we', padx=(0, 10))
        
        # Кнопка для выбора тикеров
        ttk.Button(ticker_input_frame, text="...", command=self.show_ticker_selection, width=3).grid(row=0, column=1)

        ttk.Label(main_frame, text="Начальная дата (ГГГГ-ММ-ДД):", font=('Arial', 12)).grid(row=1, column=0, sticky='w', padx=10, pady=10)
        self.start_entry = ttk.Entry(main_frame, width=25, font=('Arial', 11))
        self.start_entry.insert(0, start_date)
        self.start_entry.grid(row=1, column=1, padx=10, pady=10, sticky='w')

        ttk.Label(main_frame, text="Конечная дата (ГГГГ-ММ-ДД):", font=('Arial', 12)).grid(row=2, column=0, sticky='w', padx=10, pady=10)
        self.end_entry = ttk.Entry(main_frame, width=25, font=('Arial', 11))
        self.end_entry.insert(0, end_date)
        self.end_entry.grid(row=2, column=1, padx=10, pady=10, sticky='w')

        ttk.Label(main_frame, text="Папка для сохранения:", font=('Arial', 12)).grid(row=3, column=0, sticky='w', padx=10, pady=10)
        self.path_entry = ttk.Entry(main_frame, width=100, font=('Arial', 11))
        self.path_entry.insert(0, self.default_out_path)
        self.path_entry.grid(row=3, column=1, padx=10, pady=10, sticky='we')
        ttk.Button(main_frame, text="Обзор", command=self.browse_folder, width=15).grid(row=3, column=2, padx=10, pady=10)

        # Чекбоксы для таймфреймов
        ttk.Label(main_frame, text="Таймфреймы:", font=('Arial', 12, 'bold')).grid(row=4, column=0, sticky='nw', padx=10, pady=10)
        
        self.timeframe_frame = tk.Frame(main_frame)
        self.timeframe_frame.grid(row=4, column=1, columnspan=2, sticky='w', padx=10, pady=10)
        
        self.var_1min = tk.BooleanVar(value=True)
        self.var_5min = tk.BooleanVar(value=True)
        self.var_15min = tk.BooleanVar(value=True)
        self.var_1h = tk.BooleanVar(value=True)
        self.var_4h = tk.BooleanVar(value=True)
        self.var_1d = tk.BooleanVar(value=True)
        
        frame_row1 = tk.Frame(self.timeframe_frame)
        frame_row1.pack(fill='x', pady=3)
        
        tk.Checkbutton(frame_row1, text="1 минута", variable=self.var_1min, font=('Arial', 11), 
                      width=12, anchor='w').pack(side='left', padx=15)
        tk.Checkbutton(frame_row1, text="5 минут", variable=self.var_5min, font=('Arial', 11), 
                      width=12, anchor='w').pack(side='left', padx=15)
        tk.Checkbutton(frame_row1, text="15 минут", variable=self.var_15min, font=('Arial', 11), 
                      width=12, anchor='w').pack(side='left', padx=15)
        tk.Checkbutton(frame_row1, text="1 час", variable=self.var_1h, font=('Arial', 11), 
                      width=12, anchor='w').pack(side='left', padx=15)
        
        frame_row2 = tk.Frame(self.timeframe_frame)
        frame_row2.pack(fill='x', pady=3)
        
        tk.Checkbutton(frame_row2, text="4 часа", variable=self.var_4h, font=('Arial', 11), 
                      width=12, anchor='w').pack(side='left', padx=15)
        tk.Checkbutton(frame_row2, text="1 день", variable=self.var_1d, font=('Arial', 11), 
                      width=12, anchor='w').pack(side='left', padx=15)

        # Выбор формата файла
        ttk.Label(main_frame, text="Формат файлов:", font=('Arial', 12, 'bold')).grid(row=5, column=0, sticky='w', padx=10, pady=10)
        self.format_frame = tk.Frame(main_frame)
        self.format_frame.grid(row=5, column=1, columnspan=2, sticky='w', padx=10, pady=10)
        
        self.file_format = tk.StringVar(value="csv")
        tk.Radiobutton(self.format_frame, text="CSV", variable=self.file_format, 
                      value="csv", font=('Arial', 11)).pack(side='left', padx=15)
        tk.Radiobutton(self.format_frame, text="TXT", variable=self.file_format, 
                      value="txt", font=('Arial', 11)).pack(side='left', padx=15)

        # Оптимизация скорости
        ttk.Label(main_frame, text="Режим загрузки:", font=('Arial', 12, 'bold')).grid(row=6, column=0, sticky='w', padx=10, pady=10)
        self.optimize_frame = tk.Frame(main_frame)
        self.optimize_frame.grid(row=6, column=1, columnspan=2, sticky='w', padx=10, pady=10)
        
        self.only_weekdays = tk.BooleanVar(value=True)
        tk.Checkbutton(self.optimize_frame, text="Качать данные только по будням", 
                      variable=self.only_weekdays, font=('Arial', 11)).pack(side='left', padx=15)

        # Кнопка загрузки
        button_frame = tk.Frame(main_frame)
        button_frame.grid(row=7, column=0, columnspan=3, pady=20)
        
        self.download_btn = tk.Button(button_frame, text="СКАЧАТЬ ДАННЫЕ", command=self.start_download_thread, 
                                     font=('Arial', 12, 'bold'), width=20, height=2,
                                     bg='#0078D7', fg='white', relief='raised', bd=3)
        self.download_btn.pack(side='left', padx=20)

        # Прогресс бары
        ttk.Label(main_frame, text="Общий прогресс:", font=('Arial', 11)).grid(row=8, column=0, sticky='w', padx=10, pady=5)
        self.progress = ttk.Progressbar(main_frame, orient='horizontal', length=1400, mode='determinate')
        self.progress.grid(row=8, column=1, columnspan=2, padx=10, pady=5, sticky='we')

        ttk.Label(main_frame, text="Прогресс загрузки дня:", font=('Arial', 11)).grid(row=9, column=0, sticky='w', padx=10, pady=5)
        self.day_progress = ttk.Progressbar(main_frame, orient='horizontal', length=1400, mode='determinate')
        self.day_progress.grid(row=9, column=1, columnspan=2, padx=10, pady=5, sticky='we')

        # Лог
        ttk.Label(main_frame, text="Лог выполнения:", font=('Arial', 12, 'bold')).grid(row=10, column=0, sticky='w', padx=10, pady=10)
        
        log_frame = ttk.Frame(main_frame)
        log_frame.grid(row=11, column=0, columnspan=3, padx=10, pady=10, sticky='nsew')
        log_frame.columnconfigure(0, weight=1)
        log_frame.rowconfigure(0, weight=1)
        
        self.log_text = tk.Text(log_frame, height=25, width=150, font=('Consolas', 10))
        self.log_text.grid(row=0, column=0, sticky='nsew')
        
        scrollbar_y = ttk.Scrollbar(log_frame, command=self.log_text.yview)
        scrollbar_y.grid(row=0, column=1, sticky='ns')
        
        scrollbar_x = ttk.Scrollbar(log_frame, command=self.log_text.xview, orient='horizontal')
        scrollbar_x.grid(row=1, column=0, sticky='ew')
        
        self.log_text.config(yscrollcommand=scrollbar_y.set, xscrollcommand=scrollbar_x.set)

        # Статус бар
        self.status_var = tk.StringVar(value="Готов к работе")
        status_bar = ttk.Label(main_frame, textvariable=self.status_var, relief='sunken', anchor='w', font=('Arial', 10))
        status_bar.grid(row=12, column=0, columnspan=3, sticky='we', padx=10, pady=10)

    def setup_update_tab(self):
        """Настройка вкладки обновления данных"""
        main_frame = ttk.Frame(self.update_tab, padding="20")
        main_frame.pack(fill='both', expand=True)
        
        # Верхняя панель с настройками
        settings_frame = ttk.Frame(main_frame)
        settings_frame.pack(fill='x', pady=(0, 20))
        
        # Строка 1: Папка с данными
        folder_frame = ttk.Frame(settings_frame)
        folder_frame.pack(fill='x', pady=(0, 10))
        
        ttk.Label(folder_frame, text="Папка с данными:", font=('Arial', 12)).pack(side='left', padx=(0, 10))
        self.update_path_entry = ttk.Entry(folder_frame, width=70, font=('Arial', 11))
        self.update_path_entry.insert(0, self.default_out_path)
        self.update_path_entry.pack(side='left', fill='x', expand=True, padx=(0, 10))
        
        ttk.Button(folder_frame, text="...", command=self.browse_update_folder, width=5).pack(side='left', padx=(0, 10))
        
        # Строка 2: Дата обновления и кнопки
        date_buttons_frame = ttk.Frame(settings_frame)
        date_buttons_frame.pack(fill='x', pady=(0, 10))
        
        ttk.Label(date_buttons_frame, text="Обновить до даты:", font=('Arial', 12)).pack(side='left', padx=(0, 10))
        self.update_end_date = ttk.Entry(date_buttons_frame, width=15, font=('Arial', 11))
        self.update_end_date.insert(0, datetime.now().strftime('%Y-%m-%d'))
        self.update_end_date.pack(side='left', padx=(0, 20))
        
        ttk.Button(date_buttons_frame, text="Сканировать файлы", command=self.scan_files, 
                  width=20).pack(side='left', padx=(0, 10))
        
        self.update_btn = tk.Button(date_buttons_frame, text="ОБНОВИТЬ", command=self.start_update_thread, 
                                   font=('Arial', 12, 'bold'), width=15, height=1,
                                   bg='#28A745', fg='white', relief='raised', bd=3)
        self.update_btn.pack(side='left', padx=(0, 10))
        
        # Кнопка генерации таймфреймов
        self.generate_btn = tk.Button(date_buttons_frame, text="ГЕНЕРИРОВАТЬ", command=self.show_generate_timeframes, 
                                     font=('Arial', 12, 'bold'), width=15, height=1,
                                     bg='#FF9800', fg='white', relief='raised', bd=3)
        self.generate_btn.pack(side='left')
        
        # Фрейм для списка тикеров
        list_frame = ttk.Frame(main_frame)
        list_frame.pack(fill='both', expand=True)
        
        # Заголовки таблицы
        header_frame = ttk.Frame(list_frame)
        header_frame.pack(fill='x', pady=(0, 5))
        
        ttk.Label(header_frame, text="Выбрать", font=('Arial', 11, 'bold'), width=8).pack(side='left', padx=(20, 10))
        ttk.Label(header_frame, text="Тикер", font=('Arial', 11, 'bold'), width=15).pack(side='left', padx=10)
        ttk.Label(header_frame, text="Последняя дата", font=('Arial', 11, 'bold'), width=20).pack(side='left', padx=10)
        ttk.Label(header_frame, text="Кол-во свечей", font=('Arial', 11, 'bold'), width=15).pack(side='left', padx=10)
        ttk.Label(header_frame, text="Таймфреймы", font=('Arial', 11, 'bold'), width=20).pack(side='left', padx=10)
        
        # Создаем скроллируемый фрейм для списка тикеров
        canvas = tk.Canvas(list_frame, bg='white')
        scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
        self.scrollable_frame = ttk.Frame(canvas)
        
        self.scrollable_frame.bind(
            "<Configure>",
            lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
        )
        
        canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
        canvas.configure(yscrollcommand=scrollbar.set)
        
        canvas.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")
        
        # Прогресс бар для обновления
        ttk.Label(main_frame, text="Прогресс обновления:", font=('Arial', 11)).pack(anchor='w', pady=(20, 5))
        self.update_progress = ttk.Progressbar(main_frame, orient='horizontal', mode='determinate')
        self.update_progress.pack(fill='x', pady=(0, 10))
        
        # Лог обновления
        ttk.Label(main_frame, text="Лог обновления:", font=('Arial', 12, 'bold')).pack(anchor='w', pady=(10, 5))
        
        update_log_frame = ttk.Frame(main_frame)
        update_log_frame.pack(fill='both', expand=True)
        
        self.update_log_text = tk.Text(update_log_frame, height=15, width=150, font=('Consolas', 10))
        self.update_log_text.pack(side='left', fill='both', expand=True)
        
        update_scrollbar = ttk.Scrollbar(update_log_frame, command=self.update_log_text.yview)
        update_scrollbar.pack(side='right', fill='y')
        self.update_log_text.config(yscrollcommand=update_scrollbar.set)

    def setup_docs_tab(self):
        """Настройка вкладки документации"""
        docs_frame = ttk.Frame(self.docs_tab)
        docs_frame.pack(fill='both', expand=True)
        
        text_frame = ttk.Frame(docs_frame)
        text_frame.pack(fill='both', expand=True, padx=10, pady=10)
        
        docs_text = tk.Text(text_frame, wrap='word', font=('Arial', 11), padx=10, pady=10)
        docs_text.pack(side='left', fill='both', expand=True)
        
        scrollbar = ttk.Scrollbar(text_frame, command=docs_text.yview)
        scrollbar.pack(side='right', fill='y')
        docs_text.config(yscrollcommand=scrollbar.set)
        
        documentation = self.get_documentation()
        docs_text.insert('1.0', documentation)
        docs_text.config(state='disabled')

    def get_documentation(self):
        """Возвращает текстовую документацию"""
        return """
        MOEX DATA DOWNLOADER - ПОЛНАЯ ДОКУМЕНТАЦИЯ

        📋 ОБЩЕЕ ОПИСАНИЕ

        MOEX Data Downloader - это специализированное приложение для загрузки, обновления и обработки исторических биржевых данных с Московской биржи (MOEX). Программа предоставляет удобный графический интерфейс для работы с котировками акций.

        🚀 АЛГОРИТМ РАБОТЫ ПРИЛОЖЕНИЯ

        Основной принцип:
        1. Первоначальная загрузка → Регулярное обновление → Генерация таймфреймов (при необходимости)
        2. Все таймфреймы (кроме 1 минуты) генерируются из минутных данных
        3. Для обновления данных необходимы файлы *_1min.csv (или *_1min.txt)

        Детальный процесс:

        1. ЗАГРУЗКА ДАННЫХ (вкладка "Загрузка данных")
        - Загружаются 1-минутные данные с MOEX API
        - Автоматически создаются выбранные таймфреймы на основе минутных данных
        - Сохраняются файлы в формате: ТИКЕР_ТАЙМФРЕЙМ.расширение

        2. ОБНОВЛЕНИЕ ДАННЫХ (вкладка "Обновление данных")
        - Сканируются существующие минутные файлы (*_1min.*)
        - Определяется последняя доступная дата для каждого тикера
        - Загружаются только новые данные с даты, следующей за последней
        - Автоматически обновляются все существующие таймфреймы

        3. ГЕНЕРАЦИЯ ТАЙМФРЕЙМОВ (ДОПОЛНИТЕЛЬНАЯ ФУНКЦИЯ)
        - Необязательная операция - создание недостающих таймфреймов
        - Используется, если нужно добавить таймфреймы к уже существующим данным
        - Рекомендуется создавать все нужные таймфреймы при первоначальной загрузке

        🏗️ АЛГОРИТМ ПРЕОБРАЗОВАНИЯ ТАЙМФРЕЙМОВ

        Из 1-минутных данных создаются:

        Таймфрейм   | Правило формирования
        ------------|---------------------
        5 минут     | Группировка по 5 минутным интервалам
        15 минут    | Группировка по 15 минутным интервалам
        1 час       | Группировка по часовым интервалам
        4 часа      | Группировка по 4-часовым интервалам
        1 день      | Группировка по дневным интервалам

        Формирование свечей:
        - OPEN   - цена открытия первой свечи в интервале
        - HIGH   - максимальная цена из всех свечей интервала
        - LOW    - минимальная цена из всех свечей интервала
        - CLOSE  - цена закрытия последней свечи интервала
        - VOLUME - суммарный объем всех свечей интервала

        📊 ОПИСАНИЕ ИНТЕРФЕЙСА

        ВКЛАДКА "ЗАГРУЗКА ДАННЫХ"

        Поля ввода:
        - Тикеры - список тикеров через запятую (кнопка "..." для выбора из списка)
          *По умолчанию загружаются тикеры из индекса IMOEX через API MOEX:*
          *API вызов: https://iss.moex.com/iss/statistics/engines/stock/markets/index/analytics/IMOEX.json*
          *Это обеспечивает актуальный список ликвидных акций*
        - Начальная дата - дата начала загрузки в формате ГГГГ-ММ-ДД
        - Конечная дата - дата окончания загрузки
        - Папка для сохранения - путь для сохранения файлов

        Настройки:
        - Таймфреймы - выбор временных интервалов для создания
        - Формат файлов - CSV (с угловыми скобками) или TXT (без скобок)
        - Только будни - исключать выходные дни из загрузки

        Кнопки:
        - "..." (возле тикеров) - открывает окно выбора тикеров из списка MOEX
        - "Обзор" - выбор папки для сохранения
        - "СКАЧАТЬ ДАННЫЕ" - запуск процесса загрузки

        Индикаторы:
        - Общий прогресс - прогресс по всем тикерам и таймфреймам
        - Прогресс загрузки дня - прогресс загрузки текущего дня
        - Лог выполнения - подробный журнал операций
        - Статусная строка - текущее состояние приложения

        ВКЛАДКА "ОБНОВЛЕНИЕ ДАННЫХ"

        Поля ввода:
        - Папка с данными - путь к папке с ранее загруженными данными
        - Обновить до даты - дата, до которой обновлять данные

        Кнопки:
        - "..." - выбор папки с данными
        - "Сканировать файлы" - обновление списка тикеров
        - "ОБНОВИТЬ" - запуск обновления выбранных тикеров
        - "ГЕНЕРИРОВАТЬ" - создание недостающих таймфреймов (дополнительная функция)

        Таблица тикеров:
        - Выбрать - чекбокс для выбора тикера для обновления
        - Тикер - название тикера
        - Последняя дата - дата последней доступной свечи
        - Кол-во свечей - общее количество свечей в файле
        - Таймфреймы - список доступных таймфреймов

        Индикаторы:
        - Прогресс обновления - общий прогресс обновления
        - Лог обновления - журнал операций обновления

        🔧 ТЕХНИЧЕСКИЕ ДЕТАЛИ ЗАГРУЗКИ

        Почему данные загружаются порциями?
        MOEX API имеет ограничение на количество возвращаемых свечей (максимум 500 свечей за один запрос). Поэтому для загрузки полного дня данных применяется алгоритм последовательных запросов:

        Пример загрузки данных за один день:
        Первый запрос:  с 09:30:00 до [время последней свечи + 1 минута]
        Второй запрос:  с [время последней свечи] до конца дня
        и т.д., пока не будут получены все свечи дня

        Конкретный API вызов:
        url = "http://iss.moex.com/iss/engines/stock/markets/shares/securities/GAZP/candles.json"
        params = {
            'from': '2024-01-15 09:30:00',
            'till': '2024-01-15 18:45:00',
            'interval': 1,
            'iss.meta': 'off'
        }

        Этот подход обеспечивает:
        ✅ Полноту данных (все минутные свечи за день)
        ✅ Обход ограничений API
        ✅ Стабильность загрузки при большом объеме данных

        💾 ФОРМАТЫ ФАЙЛОВ

        CSV формат (с угловыми скобками):
        <DATE>,<TIME>,<OPEN>,<HIGH>,<LOW>,<CLOSE>,<VOL>
        20231201,100000,315.5000000,315.8000000,315.2000000,315.6000000,15000

        TXT формат (табуляция):
        DATE    TIME    OPEN    HIGH    LOW     CLOSE   VOL
        20231201        100000  315.5000000     315.8000000     315.2000000     315.6000000     15000

        🎯 РЕКОМЕНДАЦИИ ПО ИСПОЛЬЗОВАНИЮ

        Для новых пользователей:
        1. Начните с вкладки "Загрузка данных"
        2. Выберите тикеры через кнопку "..." (рекомендуется использовать список IMOEX)
        3. Укажите период загрузки (рекомендуется 30-60 дней для начала)
        4. Выберите все нужные таймфреймы сразу
        5. Нажмите "СКАЧАТЬ ДАННЫЕ"

        Для регулярного использования:
        1. Используйте вкладку "Обновление данных"
        2. Укажите папку с ранее загруженными данными
        3. Нажмите "Сканировать файлы"
        4. Выберите тикеры для обновления
        5. Укажите дату обновления и нажмите "ОБНОВИТЬ"

        При добавлении новых таймфреймов (редкий случай):
        1. Перейдите на вкладку "Обновление данных"
        2. Нажмите "ГЕНЕРИРОВАТЬ"
        3. Выберите нужные таймфреймы
        4. Нажмите "Сгенерировать"

        ⚠️ ВАЖНЫЕ ЗАМЕЧАНИЯ

        - Для работы обновления необходимы файлы *_1min.*
        - Все таймфреймы (кроме 1min) создаются из минутных данных
        - При обновлении данных автоматически обновляются все существующие таймфреймы
        - Рекомендуется использовать формат CSV для лучшей совместимости
        - При первом использовании создается папка 'out' в директории программы
        - Загрузка данных происходит порциями из-за ограничений MOEX API (500 свечей за запрос)

        ---
        Версия программы: 1.0
        Автор: https://t.me/GeorgyBlog
        ---
        """

    def show_generate_timeframes(self):
        """Показывает окно генерации таймфреймов"""
        # Получаем выбранные тикеры
        selected_tickers = []
        for ticker, data in self.ticker_vars.items():
            if data['var'].get():
                selected_tickers.append(ticker)
        
        if not selected_tickers:
            messagebox.showwarning("Внимание", "Выберите тикеры для генерации таймфреймов")
            return
        
        selection_window = tk.Toplevel(self.root)
        selection_window.title("Генерация таймфреймов")
        selection_window.geometry("1200x750")
        selection_window.transient(self.root)
        selection_window.grab_set()
        
        # Центрируем окно
        selection_window.update_idletasks()
        x = (self.root.winfo_screenwidth() - 1200) // 2
        y = (self.root.winfo_screenheight() - 750) // 2
        selection_window.geometry(f"1200x750+{x}+{y}")
        
        main_frame = ttk.Frame(selection_window, padding="20")
        main_frame.pack(fill='both', expand=True)
        
        ttk.Label(main_frame, text="Выберите таймфреймы для генерации:", 
                 font=('Arial', 14, 'bold')).pack(anchor='w', pady=(0, 20))
        
        # Фрейм для чекбоксов таймфреймов
        timeframe_frame = tk.Frame(main_frame)
        timeframe_frame.pack(fill='x', pady=(0, 20))
        
        # Переменные для чекбоксов - УБИРАЕМ 1min
        var_5min = tk.BooleanVar(value=True)
        var_15min = tk.BooleanVar(value=True)
        var_1h = tk.BooleanVar(value=True)
        var_4h = tk.BooleanVar(value=True)
        var_1d = tk.BooleanVar(value=True)
        
        # Первый ряд чекбоксов (3 таймфрейма вместо 4)
        frame_row1 = tk.Frame(timeframe_frame)
        frame_row1.pack(fill='x', pady=15)
        
        # УБИРАЕМ ЧЕКБОКС "1 минута"
        tk.Checkbutton(frame_row1, text="5 минут", variable=var_5min, font=('Arial', 12), 
                      width=15, anchor='w').pack(side='left', padx=40)
        tk.Checkbutton(frame_row1, text="15 минут", variable=var_15min, font=('Arial', 12), 
                      width=15, anchor='w').pack(side='left', padx=40)
        tk.Checkbutton(frame_row1, text="1 час", variable=var_1h, font=('Arial', 12), 
                      width=15, anchor='w').pack(side='left', padx=40)
        
        # Второй ряд чекбоксов (2 таймфрейма)
        frame_row2 = tk.Frame(timeframe_frame)
        frame_row2.pack(fill='x', pady=15)
        
        tk.Checkbutton(frame_row2, text="4 часа", variable=var_4h, font=('Arial', 12), 
                      width=15, anchor='w').pack(side='left', padx=40)
        tk.Checkbutton(frame_row2, text="1 день", variable=var_1d, font=('Arial', 12), 
                      width=15, anchor='w').pack(side='left', padx=40)
        
        # Информация о выбранных тикерах
        info_frame = ttk.Frame(main_frame)
        info_frame.pack(fill='x', pady=20)
        
        tickers_text = f"Будут обработаны тикеры: {', '.join(selected_tickers)}"
        ttk.Label(info_frame, text=tickers_text, 
                 font=('Arial', 11), wraplength=1150).pack(anchor='w')
        
        # Добавляем пояснение о минутных данных с УЧЕТОМ ФОРМАТА ФАЙЛОВ
        note_frame = ttk.Frame(main_frame)
        note_frame.pack(fill='x', pady=(0, 10))
        
        # Получаем текущий формат файлов из основной вкладки
        file_format = self.file_format.get()
        extension = file_format.lower()
        
        note_text = f"Примечание: для генерации используются существующие минутные данные (*_1min.{extension})"
        ttk.Label(note_frame, text=note_text, font=('Arial', 10), 
                 foreground='gray', wraplength=1150).pack(anchor='w')
        
        # Заполнитель для сдвига кнопок вниз
        spacer_frame = ttk.Frame(main_frame)
        spacer_frame.pack(fill='both', expand=True)
        
        # Кнопки
        button_frame = ttk.Frame(main_frame)
        button_frame.pack(fill='x', pady=(0, 10))
        
        ttk.Button(button_frame, text="Сгенерировать", 
                  command=lambda: self.generate_timeframes(selected_tickers, {
                      # УБИРАЕМ 1min из передаваемых параметров
                      '5min': var_5min.get(),
                      '15min': var_15min.get(), 
                      '1h': var_1h.get(),
                      '4h': var_4h.get(),
                      '1d': var_1d.get()
                  }, selection_window),
                  width=15).pack(side='right', padx=5)
        
        ttk.Button(button_frame, text="Отмена", 
                  command=selection_window.destroy,
                  width=15).pack(side='right', padx=5)

    def generate_timeframes(self, tickers, timeframes, window):
        """Генерирует выбранные таймфреймы для тикеров"""
        window.destroy()
        
        self.update_log("=" * 80)
        self.update_log("🔄 НАЧАЛО ГЕНЕРАЦИИ ТАЙМФРЕЙМОВ")
        self.update_log(f"📋 Тикеры: {', '.join(tickers)}")
        
        selected_timeframes = [tf for tf, selected in timeframes.items() if selected]
        self.update_log(f"📊 Таймфреймы: {', '.join(selected_timeframes)}")
        
        save_path = self.update_path_entry.get()
        file_format = self.file_format.get()
        
        total_tasks = len(tickers) * len(selected_timeframes)
        self.update_progress['value'] = 0
        self.update_progress['maximum'] = total_tasks
        
        task_count = 0
        successful_generations = 0
        
        for ticker in tickers:
            self.update_log(f"🔧 Обработка тикера {ticker}")
            
            # Читаем минутные данные
            minute_file = os.path.join(save_path, f"{ticker}_1min.{file_format}")
            if not os.path.exists(minute_file):
                self.update_log(f"❌ Файл минутных данных не найден: {minute_file}")
                continue
            
            try:
                minute_data = self.read_existing_file(minute_file, file_format)
                if minute_data.empty:
                    self.update_log(f"❌ Нет данных в файле {minute_file}")
                    continue
                
                self.update_log(f"📊 Загружено {len(minute_data)} минутных свечей")
                
                # Генерируем выбранные таймфреймы
                for timeframe in selected_timeframes:
                    task_count += 1
                    self.update_progress['value'] = task_count
                    
                    # Проверяем, существует ли уже файл
                    tf_file = os.path.join(save_path, f"{ticker}_{timeframe}.{file_format}")
                    if os.path.exists(tf_file):
                        self.update_log(f"ℹ️ Файл уже существует: {ticker}_{timeframe}")
                        continue
                    
                    try:
                        tf_data = self.convert_timeframe(minute_data, timeframe)
                        if not tf_data.empty:
                            self.save_to_file(tf_data, tf_file, file_format)
                            self.update_log(f"💾 Создан: {ticker}_{timeframe} ({len(tf_data)} свечей)")
                            successful_generations += 1
                        else:
                            self.update_log(f"⚠️ Нет данных для {ticker}_{timeframe}")
                    except Exception as e:
                        self.update_log(f"❌ Ошибка создания {timeframe} для {ticker}: {str(e)}")
                        
            except Exception as e:
                self.update_log(f"❌ Ошибка обработки {ticker}: {str(e)}")
        
        self.update_log("=" * 80)
        self.update_log(f"✅ ЗАВЕРШЕНО: создано {successful_generations} файлов")
        self.update_log("=" * 80)
        
        # Обновляем список файлов
        self.root.after(100, self.scan_files)
        
        messagebox.showinfo("Успех", f"Генерация завершена!\nСоздано файлов: {successful_generations}")

    def show_ticker_selection(self):
        """Показывает окно выбора тикеров"""
        selection_window = tk.Toplevel(self.root)
        selection_window.title("Выбор тикеров")
        selection_window.geometry("900x700")
        selection_window.transient(self.root)
        selection_window.grab_set()
        
        # Центрируем окно
        selection_window.update_idletasks()
        x = (self.root.winfo_screenwidth() - 900) // 2
        y = (self.root.winfo_screenheight() - 700) // 2
        selection_window.geometry(f"900x700+{x}+{y}")
        
        main_frame = ttk.Frame(selection_window, padding="15")
        main_frame.pack(fill='both', expand=True)
        
        # Заголовок и кнопки управления
        header_frame = ttk.Frame(main_frame)
        header_frame.pack(fill='x', pady=(0, 15))
        
        ttk.Label(header_frame, text="Выберите тикеры для загрузки:", font=('Arial', 12, 'bold')).pack(side='left')
        
        button_frame = ttk.Frame(header_frame)
        button_frame.pack(side='right')
        
        ttk.Button(button_frame, text="Выбрать все", command=lambda: self.select_all_tickers(ticker_vars), width=12).pack(side='left', padx=5)
        ttk.Button(button_frame, text="Снять все", command=lambda: self.deselect_all_tickers(ticker_vars), width=12).pack(side='left', padx=5)
        
        # Фрейм для поиска
        search_frame = ttk.Frame(main_frame)
        search_frame.pack(fill='x', pady=(0, 15))
        
        ttk.Label(search_frame, text="Поиск:", font=('Arial', 11)).pack(side='left', padx=(0, 10))
        search_var = tk.StringVar()
        search_entry = ttk.Entry(search_frame, textvariable=search_var, width=50, font=('Arial', 11))
        search_entry.pack(side='left', fill='x', expand=True, padx=(0, 10))
        search_entry.bind('<KeyRelease>', lambda e: self.filter_tickers(search_var.get(), ticker_frame, ticker_vars, all_tickers_data))
        
        # Фрейм для добавления тикера вручную
        manual_frame = ttk.Frame(main_frame)
        manual_frame.pack(fill='x', pady=(0, 15))
        
        ttk.Label(manual_frame, text="Добавить тикер:", font=('Arial', 11)).pack(side='left', padx=(0, 10))
        self.manual_ticker_entry = ttk.Entry(manual_frame, width=20, font=('Arial', 11))
        self.manual_ticker_entry.pack(side='left', padx=(0, 10))
        ttk.Button(manual_frame, text="Добавить", command=lambda: self.add_manual_ticker(ticker_vars, ticker_frame, all_tickers_data), width=10).pack(side='left')
        
        # Фрейм для списка тикеров с прокруткой
        list_frame = ttk.Frame(main_frame)
        list_frame.pack(fill='both', expand=True)
        
        canvas = tk.Canvas(list_frame, bg='white')
        scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=canvas.yview)
        ticker_frame = ttk.Frame(canvas)
        
        ticker_frame.bind(
            "<Configure>",
            lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
        )
        
        canvas.create_window((0, 0), window=ticker_frame, anchor="nw")
        canvas.configure(yscrollcommand=scrollbar.set)
        
        canvas.pack(side="left", fill="both", expand=True)
        scrollbar.pack(side="right", fill="y")
        
        # Загружаем тикеры с MOEX
        ticker_vars = {}
        all_tickers_data = {}  # Будем хранить данные о всех тикерах
        self.load_moex_tickers(ticker_frame, ticker_vars, all_tickers_data)
        
        # Добавляем тикеры из текущего списка, которых нет в MOEX
        self.add_custom_tickers(ticker_frame, ticker_vars, all_tickers_data)
        
        # Кнопки внизу
        bottom_frame = ttk.Frame(main_frame)
        bottom_frame.pack(fill='x', pady=(15, 0))
        
        ttk.Button(bottom_frame, text="Применить", 
                  command=lambda: self.apply_ticker_selection(ticker_vars, selection_window),
                  width=12).pack(side='right', padx=5)
        ttk.Button(bottom_frame, text="Отмена", 
                  command=selection_window.destroy,
                  width=12).pack(side='right', padx=5)

    def add_custom_tickers(self, parent_frame, ticker_vars, all_tickers_data):
        """Добавляет кастомные тикеры из текущего списка"""
        current_tickers = [t.strip().upper() for t in self.tickers_entry.get().split(',') if t.strip()]
        
        for ticker in current_tickers:
            if ticker not in all_tickers_data:
                # Добавляем кастомный тикер
                var = tk.BooleanVar(value=True)
                ticker_vars[ticker] = var
                all_tickers_data[ticker] = {'name': 'добавлен вручную', 'is_custom': True}
                
                # Создаем строку для кастомного тикера
                row_frame = ttk.Frame(parent_frame)
                row_frame.pack(fill='x', pady=1)
                
                cb = tk.Checkbutton(row_frame, variable=var, bg='white')
                cb.pack(side='left', padx=5)
                
                ttk.Label(row_frame, text=ticker, font=('Arial', 10, 'bold'), width=10).pack(side='left', padx=5)
                ttk.Label(row_frame, text="(добавлен вручную)", font=('Arial', 10), width=70, anchor='w', foreground='green').pack(side='left', padx=5)

    def add_manual_ticker(self, ticker_vars, parent_frame, all_tickers_data):
        """Добавляет тикер вручную"""
        ticker = self.manual_ticker_entry.get().strip().upper()
        if not ticker:
            return
            
        if ticker in ticker_vars:
            messagebox.showinfo("Информация", f"Тикер {ticker} уже есть в списке")
            return
            
        # Добавляем новый тикер
        var = tk.BooleanVar(value=True)
        ticker_vars[ticker] = var
        all_tickers_data[ticker] = {'name': 'добавлен вручную', 'is_custom': True}
        
        # Создаем строку для нового тикера
        row_frame = ttk.Frame(parent_frame)
        row_frame.pack(fill='x', pady=1)
        
        cb = tk.Checkbutton(row_frame, variable=var, bg='white')
        cb.pack(side='left', padx=5)
        
        ttk.Label(row_frame, text=ticker, font=('Arial', 10, 'bold'), width=10).pack(side='left', padx=5)
        ttk.Label(row_frame, text="(добавлен вручную)", font=('Arial', 10), width=70, anchor='w', foreground='green').pack(side='left', padx=5)
        
        self.manual_ticker_entry.delete(0, tk.END)
        messagebox.showinfo("Успех", f"Тикер {ticker} добавлен")

    def load_moex_tickers(self, parent_frame, ticker_vars, all_tickers_data):
        """Загружает тикеры с MOEX и отображает их"""
        try:
            url = "https://iss.moex.com/iss/statistics/engines/stock/markets/index/analytics/IMOEX.json?iss.meta=off&limit=9999"
            response = requests.get(url, timeout=10)
            if response.status_code == 200:
                data = response.json()
                tickers_data = data['analytics']['data']
                
                # Сортируем по тикеру
                tickers_data.sort(key=lambda x: x[2])
                
                # Получаем текущие выбранные тикеры
                current_tickers = [t.strip().upper() for t in self.tickers_entry.get().split(',') if t.strip()]
                
                for i, ticker_info in enumerate(tickers_data):
                    ticker = ticker_info[2]  # ticker
                    name = ticker_info[3]   # shortnames
                    
                    # Сохраняем данные о тикере
                    all_tickers_data[ticker] = {'name': name, 'is_custom': False}
                    
                    # Проверяем, выбран ли тикер в текущем списке
                    var = tk.BooleanVar(value=(ticker in current_tickers))
                    ticker_vars[ticker] = var
                    
                    row_frame = ttk.Frame(parent_frame)
                    row_frame.pack(fill='x', pady=1)
                    
                    cb = tk.Checkbutton(row_frame, variable=var, bg='white')
                    cb.pack(side='left', padx=5)
                    
                    ttk.Label(row_frame, text=ticker, font=('Arial', 10, 'bold'), width=10).pack(side='left', padx=5)
                    ttk.Label(row_frame, text=name, font=('Arial', 10), width=70, anchor='w').pack(side='left', padx=5)
                    
            else:
                messagebox.showerror("Ошибка", "Не удалось загрузить тикеры с MOEX")
        except Exception as e:
            messagebox.showerror("Ошибка", f"Ошибка при загрузке тикеров: {str(e)}")

    def filter_tickers(self, search_text, parent_frame, ticker_vars, all_tickers_data):
        """Фильтрует тикеры по поисковому запросу"""
        for widget in parent_frame.winfo_children():
            widget.destroy()
            
        search_text = search_text.upper()
        
        for ticker, var in ticker_vars.items():
            ticker_data = all_tickers_data.get(ticker, {'name': '', 'is_custom': False})
            name = ticker_data['name']
            is_custom = ticker_data['is_custom']
            
            if search_text in ticker.upper() or search_text in name.upper():
                row_frame = ttk.Frame(parent_frame)
                row_frame.pack(fill='x', pady=1)
                
                cb = tk.Checkbutton(row_frame, variable=var, bg='white')
                cb.pack(side='left', padx=5)
                
                ttk.Label(row_frame, text=ticker, font=('Arial', 10, 'bold'), width=10).pack(side='left', padx=5)
                
                if is_custom:
                    display_text = "(добавлен вручную)"
                    color = 'green'
                    ttk.Label(row_frame, text=display_text, font=('Arial', 10), width=70, anchor='w', foreground=color).pack(side='left', padx=5)
                else:
                    ttk.Label(row_frame, text=name, font=('Arial', 10), width=70, anchor='w').pack(side='left', padx=5)

    def select_all_tickers(self, ticker_vars):
        """Выбирает все тикеры"""
        for var in ticker_vars.values():
            var.set(True)

    def deselect_all_tickers(self, ticker_vars):
        """Снимает выделение со всех тикеров"""
        for var in ticker_vars.values():
            var.set(False)

    def apply_ticker_selection(self, ticker_vars, window):
        """Применяет выбранные тикеры"""
        selected_tickers = [ticker for ticker, var in ticker_vars.items() if var.get()]
        if selected_tickers:
            self.tickers_entry.delete(0, tk.END)
            self.tickers_entry.insert(0, ", ".join(selected_tickers))
        window.destroy()

    def browse_folder(self):
        """Выбор папки для сохранения данных"""
        path = filedialog.askdirectory()
        if path:
            self.path_entry.delete(0, tk.END)
            self.path_entry.insert(0, path)

    def browse_update_folder(self):
        """Выбор папки с данными для обновления"""
        path = filedialog.askdirectory()
        if path:
            self.update_path_entry.delete(0, tk.END)
            self.update_path_entry.insert(0, path)

    def scan_files(self):
        """Сканирование файлов и отображение списка тикеров"""
        # Сохраняем текущее состояние выделения
        if hasattr(self, 'ticker_vars') and self.ticker_vars:
            self.selected_tickers_state = {}
            for ticker, data in self.ticker_vars.items():
                self.selected_tickers_state[ticker] = data['var'].get()
        
        # Очищаем предыдущий список
        for widget in self.scrollable_frame.winfo_children():
            widget.destroy()
        
        self.ticker_vars = {}
        save_path = self.update_path_entry.get()
        file_format = self.file_format.get()
        
        if not save_path:
            messagebox.showerror("Ошибка", "Укажите папку с данными")
            return
        
        # Ищем файлы с минутными данными
        self.update_log("🔍 Начинаем сканирование файлов...")
        pattern = os.path.join(save_path, f"*_1min.{file_format}")
        minute_files = glob.glob(pattern)
        
        if not minute_files:
            self.update_log("❌ Не найдены файлы с минутными данными")
            return
        
        total_files = len(minute_files)
        self.update_log(f"📁 Найдено файлов для обработки: {total_files}")
        
        # Прогресс бар для сканирования
        self.update_progress['value'] = 0
        self.update_progress['maximum'] = total_files
        
        processed_files = 0
        
        for i, file_path in enumerate(minute_files):
            # Обновляем прогресс
            processed_files += 1
            self.update_progress['value'] = processed_files
            progress_percent = (processed_files / total_files) * 100
            
            # Извлекаем тикер из имени файла
            filename = os.path.basename(file_path)
            ticker = filename.split('_')[0].upper()
            
            self.update_log(f"📊 Обрабатываем файл {processed_files}/{total_files} ({progress_percent:.1f}%): {filename}")
            
            # Создаем фрейм для строки
            row_frame = ttk.Frame(self.scrollable_frame)
            row_frame.pack(fill='x', pady=2)
            
            # Переменная для чекбокса - восстанавливаем состояние если было сохранено
            var = tk.BooleanVar(value=self.selected_tickers_state.get(ticker, True))
            self.ticker_vars[ticker] = {'var': var, 'file_path': file_path}
            
            # Чекбокс
            cb = tk.Checkbutton(row_frame, variable=var, bg='white')
            cb.pack(side='left', padx=(20, 10))
            
            # Тикер
            ttk.Label(row_frame, text=ticker, font=('Arial', 11), width=15).pack(side='left', padx=10)
            
            # Читаем данные и определяем последнюю дату
            try:
                existing_data = self.read_existing_file(file_path, file_format)
                if not existing_data.empty:
                    last_date = existing_data.index.max().strftime('%Y-%m-%d %H:%M:%S')
                    candle_count = len(existing_data)
                    
                    # Находим все таймфреймы для этого тикера
                    timeframes = self.find_available_timeframes(ticker, save_path, file_format)
                    timeframe_text = ", ".join(timeframes) if timeframes else "только 1min"
                    
                    self.update_log(f"   ✅ {ticker}: {candle_count} свечей, последняя дата: {last_date}")
                else:
                    last_date = "Нет данных"
                    candle_count = 0
                    timeframe_text = "нет данных"
                    self.update_log(f"   ⚠️ {ticker}: файл пустой или поврежден")
            except Exception as e:
                last_date = "Ошибка чтения"
                candle_count = 0
                timeframe_text = f"ошибка: {str(e)}"
                self.update_log(f"   ❌ {ticker}: ошибка чтения - {str(e)}")
            
            # Последняя дата
            ttk.Label(row_frame, text=last_date, font=('Arial', 11), width=20).pack(side='left', padx=10)
            
            # Количество свечей
            ttk.Label(row_frame, text=str(candle_count), font=('Arial', 11), width=15).pack(side='left', padx=10)
            
            # Таймфреймы
            ttk.Label(row_frame, text=timeframe_text, font=('Arial', 11), width=20).pack(side='left', padx=10)
            
            # Сохраняем информацию о тикере
            self.ticker_vars[ticker]['last_date'] = last_date
            self.ticker_vars[ticker]['candle_count'] = candle_count
            self.ticker_vars[ticker]['timeframes'] = timeframe_text
            
            # Обновляем интерфейс после каждого файла
            if i % 5 == 0:  # Обновляем каждые 5 файлов для производительности
                self.root.update_idletasks()
        
        # Сбрасываем прогресс бар после завершения сканирования
        self.update_progress['value'] = 0
        self.update_log(f"✅ Сканирование завершено! Обработано файлов: {total_files}")

    def find_available_timeframes(self, ticker, save_path, file_format):
        """Находит все доступные таймфреймы для тикера"""
        timeframes = []
        for tf in ['1min', '5min', '15min', '1h', '4h', '1d']:
            file_path = os.path.join(save_path, f"{ticker}_{tf}.{file_format}")
            if os.path.exists(file_path):
                # Для краткости используем сокращенные обозначения
                short_tf = tf.replace('min', 'm').replace('1d', 'D')
                timeframes.append(short_tf)
        return timeframes

    def update_log(self, message):
        """Логирование на вкладке обновления"""
        self.update_log_text.insert(tk.END, f"{datetime.now().strftime('%H:%M:%S')} - {message}\n")
        self.update_log_text.see(tk.END)
        self.root.update_idletasks()

    def start_download_thread(self):
        """Запуск загрузки в отдельном потоке"""
        if self.downloading:
            return
            
        self.downloading = True
        self.download_btn.config(state='disabled', bg='gray')
        thread = threading.Thread(target=self.download_data)
        thread.daemon = True
        thread.start()

    def start_update_thread(self):
        """Запуск обновления данных в отдельном потоке"""
        if self.downloading:
            return
            
        self.downloading = True
        self.update_btn.config(state='disabled', bg='gray')
        thread = threading.Thread(target=self.update_selected_data)
        thread.daemon = True
        thread.start()

    def log(self, message):
        """Метод для логирования в текстовое поле"""
        self.log_text.insert(tk.END, f"{datetime.now().strftime('%H:%M:%S')} - {message}\n")
        self.log_text.see(tk.END)
        self.root.update_idletasks()

    def update_status(self, message):
        """Обновление статусной строки"""
        self.status_var.set(message)
        self.root.update_idletasks()

    def is_weekend(self, date):
        """Проверка выходного дня"""
        return date.weekday() >= 5

    def download_data(self):
        """Полная загрузка данных"""
        try:
            self.update_status("Начинаем загрузку данных...")
            tickers = [t.strip().upper() for t in self.tickers_entry.get().split(',')]
            start_date = self.start_entry.get()
            end_date = self.end_entry.get()
            save_path = self.path_entry.get()
            file_format = self.file_format.get()
            only_weekdays = self.only_weekdays.get()

            if not all([tickers, start_date, end_date, save_path]):
                messagebox.showerror("Ошибка", "Все поля должны быть заполнены")
                self.update_status("Ошибка: не все поля заполнены")
                return

            # Получаем выбранные таймфреймы
            timeframes = self.get_selected_timeframes()
            if not timeframes:
                messagebox.showerror("Ошибка", "Выберите хотя бы один таймфрейм")
                self.update_status("Ошибка: не выбран таймфрейм")
                return

            os.makedirs(save_path, exist_ok=True)
            total_tasks = len(tickers) * len(timeframes)
            self.progress['value'] = 0
            self.progress['maximum'] = total_tasks

            task_count = 0
            successful_downloads = 0
            
            self.log("=" * 80)
            self.log(f"🚀 НАЧАЛО ПОЛНОЙ ЗАГРУЗКИ ДАННЫХ")
            self.log(f"Тикеры: {', '.join(tickers)}")
            self.log(f"Период: {start_date} - {end_date}")
            self.log(f"Таймфреймы: {', '.join(timeframes)}")
            self.log(f"Формат файлов: {file_format.upper()}")
            self.log(f"Только будни: {'ДА' if only_weekdays else 'НЕТ'}")
            self.log(f"Папка сохранения: {save_path}")
            self.log("=" * 80)
            
            for ticker in tickers:
                if not self.downloading:
                    break
                    
                self.current_ticker = ticker
                self.update_status(f"Обработка тикера {ticker}...")
                self.log(f"📥 Начинаем загрузку {ticker}")
                try:
                    # Загружаем 1-минутные данные (основные)
                    minute_data = self.download_1min_data(ticker, start_date, end_date, only_weekdays)
                    
                    if minute_data.empty:
                        self.log(f"❌ Нет данных для {ticker}")
                        continue

                    self.log(f"✅ Загружено {len(minute_data)} минутных свечей для {ticker}")

                    # Генерируем все таймфреймы из 1-минутных данных
                    for timeframe in timeframes:
                        if not self.downloading:
                            break
                            
                        task_count += 1
                        self.progress['value'] = task_count
                        self.update_status(f"Генерация {timeframe} для {ticker}...")
                        
                        self.log(f"Создание {timeframe} для {ticker}")
                        try:
                            if timeframe == '1min':
                                tf_data = self.prepare_1min_data(minute_data)
                            else:
                                tf_data = self.convert_timeframe(minute_data, timeframe)
                            
                            if not tf_data.empty:
                                extension = file_format.lower()
                                filename = os.path.join(save_path, f"{ticker}_{timeframe}.{extension}")
                                self.save_to_file(tf_data, filename, file_format)
                                self.log(f"💾 Сохранено: {filename} ({len(tf_data)} свечей)")
                                successful_downloads += 1
                            else:
                                self.log(f"⚠️ Нет данных для {ticker}_{timeframe}")
                        except Exception as e:
                            self.log(f"❌ Ошибка создания {timeframe} для {ticker}: {str(e)}")
                            
                    time.sleep(0.2)
                    
                except Exception as e:
                    self.log(f"❌ Ошибка для {ticker}: {str(e)}")

            self.downloading = False
            self.download_btn.config(state='normal', bg='#0078D7')
            self.update_status("Загрузка завершена")
            # СБРАСЫВАЕМ ПРОГРЕСС-БАР В 0
            self.progress['value'] = 0
            self.day_progress['value'] = 0
            
            self.log("=" * 80)
            self.log(f"✅ ЗАВЕРШЕНО: Создано {successful_downloads} файлов")
            self.log("=" * 80)
            
            messagebox.showinfo("Успех", f"Данные успешно скачаны!\nСоздано файлов: {successful_downloads}")
            
        except Exception as e:
            self.downloading = False
            self.download_btn.config(state='normal', bg='#0078D7')
            self.update_status("Ошибка при загрузке")
            # СБРАСЫВАЕМ ПРОГРЕСС-БАР В 0 ПРИ ОШИБКЕ
            self.progress['value'] = 0
            self.day_progress['value'] = 0
            messagebox.showerror("Ошибка", str(e))

    def update_selected_data(self):
        """Обновление выбранных тикеров"""
        try:
            self.update_log("=" * 80)
            self.update_log("🔄 НАЧАЛО ОБНОВЛЕНИЯ ВЫБРАННЫХ ТИКЕРОВ")
            
            # Получаем выбранные тикеры
            selected_tickers = []
            for ticker, data in self.ticker_vars.items():
                if data['var'].get():
                    selected_tickers.append(ticker)
            
            if not selected_tickers:
                self.update_log("❌ Не выбрано ни одного тикера для обновления")
                return
            
            self.update_log(f"📋 Выбрано тикеров: {len(selected_tickers)}")
            self.update_log(f"📊 Тикеры: {', '.join(selected_tickers)}")
            
            # Получаем конечную дату обновления
            end_date = self.update_end_date.get()
            if not end_date:
                end_date = datetime.now().strftime('%Y-%m-%d')
                self.update_log(f"📅 Дата обновления не указана, используем текущую: {end_date}")
            else:
                self.update_log(f"📅 Обновляем до даты: {end_date}")
            
            only_weekdays = self.only_weekdays.get()
            file_format = self.file_format.get()
            save_path = self.update_path_entry.get()
            
            total_tasks = len(selected_tickers)
            self.update_progress['value'] = 0
            self.update_progress['maximum'] = total_tasks
            
            task_count = 0
            successful_updates = 0
            
            for ticker in selected_tickers:
                if not self.downloading:
                    break
                    
                file_path = self.ticker_vars[ticker]['file_path']
                self.update_log(f"🔄 Обработка тикера {ticker}")
                
                try:
                    # Читаем существующие данные
                    existing_data = self.read_existing_file(file_path, file_format)
                    if existing_data.empty:
                        self.update_log(f"⚠️ Файл {ticker} пустой или поврежден")
                        continue

                    # Определяем последнюю дату в файле
                    last_date = existing_data.index.max().date()
                    start_date = (last_date + timedelta(days=1)).strftime('%Y-%m-%d')

                    self.update_log(f"📊 В файле: {len(existing_data)} свечей, последняя дата: {last_date}")
                    self.update_log(f"📥 Будет загружено с {start_date} по {end_date}")

                    # Загружаем новые данные
                    new_data = self.download_1min_data(ticker, start_date, end_date, only_weekdays)
                    
                    if new_data.empty:
                        self.update_log(f"ℹ️ Нет новых данных для {ticker}")
                        successful_updates += 1
                        continue

                    self.update_log(f"✅ Загружено {len(new_data)} новых свечей")

                    # Объединяем данные
                    combined_data = pd.concat([existing_data, new_data], ignore_index=False)
                    combined_data = combined_data[~combined_data.index.duplicated(keep='last')]
                    combined_data = combined_data.sort_index()

                    # Сохраняем обновленный минутный файл
                    task_count += 1
                    self.update_progress['value'] = task_count
                    
                    save_data = self.prepare_1min_data(combined_data)
                    self.save_to_file(save_data, file_path, file_format)
                    self.update_log(f"💾 Обновлен: {ticker} ({len(combined_data)} свечей)")

                    # Обновляем существующие таймфреймы
                    self.update_existing_timeframes(ticker, save_path, file_format, combined_data)
                    
                    successful_updates += 1

                except Exception as e:
                    self.update_log(f"❌ Ошибка обновления {ticker}: {str(e)}")

            self.downloading = False
            self.update_btn.config(state='normal', bg='#28A745')
            # СБРАСЫВАЕМ ПРОГРЕСС-БАР В 0 ПОСЛЕ ОБНОВЛЕНИЯ
            self.update_progress['value'] = 0
            
            # Автоматическое обновление списка после завершения
            self.root.after(100, self.scan_files)
            
            self.update_log("=" * 80)
            self.update_log(f"✅ ЗАВЕРШЕНО ОБНОВЛЕНИЕ: обработано {successful_updates} тикеров")
            self.update_log("=" * 80)
            
            messagebox.showinfo("Успех", f"Данные успешно обновлены!\nОбработано тикеров: {successful_updates}")
            
        except Exception as e:
            self.downloading = False
            self.update_btn.config(state='normal', bg='#28A745')
            # СБРАСЫВАЕМ ПРОГРЕСС-БАР В 0 ПРИ ОШИБКЕ
            self.update_progress['value'] = 0
            self.update_log(f"❌ Ошибка при обновлении: {str(e)}")
            messagebox.showerror("Ошибка", str(e))

    def update_existing_timeframes(self, ticker, save_path, file_format, minute_data):
        """Обновление существующих таймфреймов для тикера"""
        # Ищем все существующие файлы для этого тикера
        pattern = os.path.join(save_path, f"{ticker}_*.{file_format}")
        existing_files = glob.glob(pattern)
        
        for file_path in existing_files:
            filename = os.path.basename(file_path)
            # Пропускаем минутный файл, он уже обновлен
            if filename.endswith(f"_1min.{file_format}"):
                continue
                
            # Извлекаем таймфрейм из имени файла
            try:
                timeframe = filename.replace(f"{ticker}_", "").replace(f".{file_format}", "")
                if timeframe in ['5min', '15min', '1h', '4h', '1d']:
                    self.update_log(f"🔄 Обновление {timeframe} для {ticker}")
                    try:
                        tf_data = self.convert_timeframe(minute_data, timeframe)
                        if not tf_data.empty:
                            self.save_to_file(tf_data, file_path, file_format)
                            self.update_log(f"💾 Обновлен: {ticker}_{timeframe} ({len(tf_data)} свечей)")
                        else:
                            self.update_log(f"⚠️ Нет данных для {ticker}_{timeframe}")
                    except Exception as e:
                        self.update_log(f"❌ Ошибка обновления {timeframe} для {ticker}: {str(e)}")
            except Exception as e:
                self.update_log(f"❌ Ошибка обработки файла {filename}: {str(e)}")

    def get_selected_timeframes(self):
        """Получение списка выбранных таймфреймов"""
        timeframes = []
        if self.var_1min.get(): timeframes.append('1min')
        if self.var_5min.get(): timeframes.append('5min')
        if self.var_15min.get(): timeframes.append('15min')
        if self.var_1h.get(): timeframes.append('1h')
        if self.var_4h.get(): timeframes.append('4h')
        if self.var_1d.get(): timeframes.append('1d')
        return timeframes

    def download_1min_data(self, ticker, start_date, end_date, only_weekdays=True):
        """Загрузка 1-минутных данных с MOEX API"""
        all_data = []
        current_start = datetime.strptime(start_date, '%Y-%m-%d')
        end = datetime.strptime(end_date, '%Y-%m-%d')
        
        total_days = 0
        temp_date = current_start
        while temp_date <= end:
            if not only_weekdays or not self.is_weekend(temp_date):
                total_days += 1
            temp_date += timedelta(days=1)
            
        self.day_progress['value'] = 0
        self.day_progress['maximum'] = total_days
        
        self.log(f"📥 Загрузка 1-минутных данных для {ticker}")
        self.log(f"Период: {start_date} - {end_date}")
        self.log(f"Всего дней для загрузки: {total_days} {'(только будни)' if only_weekdays else ''}")
        
        days_loaded = 0
        
        while current_start <= end and self.downloading:
            if only_weekdays and self.is_weekend(current_start):
                current_start += timedelta(days=1)
                continue
                
            try:
                date_str = current_start.strftime('%Y-%m-%d')
                days_loaded += 1
                self.day_progress['value'] = days_loaded
                self.update_status(f"{ticker}: день {days_loaded}/{total_days} (загрузка...)")
                
                # Загружаем данные дня частями (из-за ограничения в 500 свечей)
                day_data = []
                from_time = "00:00:00"  # ВОССТАНОВЛЕНО правильное время!
                day_requests = 0
                
                while from_time and self.downloading:
                    day_requests += 1
                    
                    url = f"http://iss.moex.com/iss/engines/stock/markets/shares/securities/{ticker}/candles.json"
                    params = {
                        'from': f"{date_str} {from_time}",
                        'till': f"{date_str} 23:59:59",
                        'interval': 1,
                        'iss.meta': 'off'
                    }
                    
                    response = requests.get(url, params=params, timeout=10)
                    if response.status_code == 200:
                        data = response.json()
                        candles = data['candles']['data']
                        
                        if candles:
                            columns = ['open', 'close', 'high', 'low', 'value', 'volume', 'begin', 'end']
                            df = pd.DataFrame(candles, columns=columns)
                            df['begin'] = pd.to_datetime(df['begin'])
                            
                            df.set_index('begin', inplace=True)
                            df = df[['open', 'high', 'low', 'close', 'volume']]
                            
                            day_data.append(df)
                            
                            if len(candles) < 500:
                                break
                                
                            last_time = df.index.max() + timedelta(minutes=1)
                            from_time = last_time.strftime('%H:%M:%S')
                            
                            self.log(f"    ⏱️  Загружено {len(candles)} свечей, продолжаем с {from_time}")
                            time.sleep(0.1)
                        else:
                            break
                    else:
                        self.log(f"  📅 {date_str}: ошибка HTTP {response.status_code}")
                        break
                        
                if day_data:
                    day_df = pd.concat(day_data, ignore_index=False)
                    all_data.append(day_df)
                    total_candles = len(day_df)
                    first_time = day_df.index.min().strftime('%H:%M:%S')
                    last_time = day_df.index.max().strftime('%H:%M:%S')
                    self.log(f"  📅 {date_str}: {total_candles} свечей за {day_requests} запр., время: {first_time} - {last_time}")
                else:
                    self.log(f"  📅 {date_str}: нет данных")
                            
            except requests.exceptions.Timeout:
                self.log(f"  📅 {date_str}: таймаут")
            except Exception as e:
                self.log(f"  📅 {date_str}: ошибка {str(e)}")
            
            current_start += timedelta(days=1)
            time.sleep(0.05)

        # Объединяем все данные и сортируем по времени
        if all_data:
            result = pd.concat(all_data, ignore_index=False)
            result = result.sort_index()
            
            if isinstance(result, pd.Series):
                result = result.to_frame().T
            
            self.log(f"✅ {ticker}: загружено {len(result)} свечей за {days_loaded} дней")
            self.update_status(f"{ticker}: завершено")
            
            return result
        else:
            self.log(f"❌ {ticker}: не удалось загрузить данные")
            return pd.DataFrame()

    def prepare_1min_data(self, df):
        """Подготовка 1-минутных данных к сохранению"""
        if df.empty:
            return df
            
        result = df.copy()
        
        # Если данные уже имеют колонки DATE и TIME, используем их
        if 'DATE' not in result.columns or 'TIME' not in result.columns:
            # Сбрасываем индекс чтобы получить колонку с временем
            result = result.reset_index()
            
            # Переименовываем временную колонку если нужно
            if 'begin' in result.columns:
                result = result.rename(columns={'begin': 'timestamp'})
            elif 'index' in result.columns:
                result = result.rename(columns={'index': 'timestamp'})
            
            # Создаем колонки DATE и TIME из временной метки
            if 'timestamp' in result.columns:
                result['DATE'] = result['timestamp'].dt.strftime('%Y%m%d')
                result['TIME'] = result['timestamp'].dt.strftime('%H%M%S')
                # Убираем временную колонку
                result = result.drop('timestamp', axis=1)
        
        # Переименовываем колонки в нужный формат (всегда в верхний регистр без скобок)
        column_mapping = {}
        if 'open' in result.columns:
            column_mapping['open'] = 'OPEN'
        if 'high' in result.columns:
            column_mapping['high'] = 'HIGH'
        if 'low' in result.columns:
            column_mapping['low'] = 'LOW'
        if 'close' in result.columns:
            column_mapping['close'] = 'CLOSE'
        if 'volume' in result.columns:
            column_mapping['volume'] = 'VOL'
        
        if column_mapping:
            result = result.rename(columns=column_mapping)
        
        # Убедимся, что все нужные колонки присутствуют
        required_columns = ['DATE', 'TIME', 'OPEN', 'HIGH', 'LOW', 'CLOSE', 'VOL']
        available_columns = [col for col in required_columns if col in result.columns]
        
        # Если каких-то колонок нет, логируем это
        missing_columns = set(required_columns) - set(available_columns)
        if missing_columns:
            self.log(f"    ⚠️ Отсутствуют колонки: {missing_columns}")
        
        return result[available_columns]

    def convert_timeframe(self, df, timeframe):
        """Конвертация в другие таймфреймов - ОКОНЧАТЕЛЬНАЯ ИСПРАВЛЕННАЯ ВЕРСИЯ"""
        if df.empty:
            return df
            
        df_copy = df.copy()
        
        # Логируем структуру данных для отладки
        self.log(f"    🔍 Структура данных для конвертации {timeframe}:")
        self.log(f"      Колонки: {list(df_copy.columns)}")
        self.log(f"      Тип индекса: {type(df_copy.index)}")
        if hasattr(df_copy.index, 'name'):
            self.log(f"      Имя индекса: {df_copy.index.name}")
        
        # Определяем правило ресемплинга
        if timeframe == '5min':
            rule = '5T'
        elif timeframe == '15min':
            rule = '15T'
        elif timeframe == '1h':
            rule = '1H'
        elif timeframe == '4h':
            rule = '4H'
        elif timeframe == '1d':
            rule = '1D'
        else:
            return df
            
        # Определяем какие колонки агрегировать
        agg_dict = {}
        
        if 'open' in df_copy.columns:
            agg_dict['open'] = 'first'
        elif 'OPEN' in df_copy.columns:
            agg_dict['OPEN'] = 'first'
            
        if 'high' in df_copy.columns:
            agg_dict['high'] = 'max'
        elif 'HIGH' in df_copy.columns:
            agg_dict['HIGH'] = 'max'
            
        if 'low' in df_copy.columns:
            agg_dict['low'] = 'min'
        elif 'LOW' in df_copy.columns:
            agg_dict['LOW'] = 'min'
            
        if 'close' in df_copy.columns:
            agg_dict['close'] = 'last'
        elif 'CLOSE' in df_copy.columns:
            agg_dict['CLOSE'] = 'last'
            
        if 'volume' in df_copy.columns:
            agg_dict['volume'] = 'sum'
        elif 'VOL' in df_copy.columns:
            agg_dict['VOL'] = 'sum'
        
        if not agg_dict:
            self.log("    ❌ Нет данных для агрегации")
            return pd.DataFrame()
        
        self.log(f"    🔧 Агрегация колонок: {list(agg_dict.keys())}")
        
        try:
            # Выполняем ресемплинг
            result = df_copy.resample(rule).agg(agg_dict).dropna()
            
            if result.empty:
                self.log(f"    ⚠️ После ресемплинга нет данных")
                return pd.DataFrame()
                
            self.log(f"    ✅ Ресемплинг выполнен: {len(result)} свечей")
            
            # Сбрасываем индекс чтобы добавить DATE и TIME
            result = result.reset_index()
            
            # Переименовываем временную колонку (может называться 'begin' или 'index')
            if 'begin' in result.columns:
                timestamp_col = 'begin'
            elif 'index' in result.columns:
                timestamp_col = 'index'
            else:
                # Если не нашли стандартные имена, берем первую колонку (это будет временная метка)
                timestamp_col = result.columns[0]
            
            # Добавляем колонки DATE и TIME
            if timeframe == '1d':
                result['DATE'] = result[timestamp_col].dt.strftime('%Y%m%d')
                result['TIME'] = '000000'
            else:
                result['DATE'] = result[timestamp_col].dt.strftime('%Y%m%d')
                result['TIME'] = result[timestamp_col].dt.strftime('%H%M%S')
            
            # Убираем временную колонку
            result = result.drop(timestamp_col, axis=1)
            
            # Переименовываем колонки в верхний регистр
            result = result.rename(columns={
                'open': 'OPEN', 'high': 'HIGH', 'low': 'LOW', 
                'close': 'CLOSE', 'volume': 'VOL'
            })
            
            # Убедимся, что все нужные колонки присутствуют
            required_columns = ['DATE', 'TIME', 'OPEN', 'HIGH', 'LOW', 'CLOSE', 'VOL']
            available_columns = [col for col in required_columns if col in result.columns]
            
            self.log(f"    💾 Подготовлено данных: {len(result)} строк, колонки: {available_columns}")
            
            return result[available_columns]
            
        except Exception as e:
            self.log(f"    ❌ Ошибка ресемплинга для {timeframe}: {str(e)}")
            import traceback
            self.log(f"    📋 Детали ошибки: {traceback.format_exc()}")
            return pd.DataFrame()

    def read_existing_file(self, file_path, file_format):
        """Чтение существующего файла с данными"""
        try:
            if file_format == 'csv':
                # CSV формат: <DATE>, <TIME>, <OPEN>, etc.
                df = pd.read_csv(file_path)
                self.log(f"   Прочитано {len(df)} строк (CSV формат)")
                
                # Проверяем колонки CSV формата
                if '<DATE>' not in df.columns or '<TIME>' not in df.columns:
                    self.log(f"❌ CSV файл не содержит колонок <DATE> и <TIME>")
                    self.log(f"   Найденные колонки: {list(df.columns)}")
                    return pd.DataFrame()
                
                # Создаем временную метку из <DATE> и <TIME>
                try:
                    # Преобразуем числовые колонки в строки с заполнением нулями
                    date_str = df['<DATE>'].astype(str).str.zfill(8)
                    time_str = df['<TIME>'].astype(str).str.zfill(6)
                    
                    # Создаем временную метку
                    df['timestamp'] = pd.to_datetime(date_str + ' ' + time_str, format='%Y%m%d %H%M%S')
                    
                    # Устанавливаем временную метку как индекс
                    df.set_index('timestamp', inplace=True)
                    
                    # Переименовываем колонки в стандартные имена
                    column_mapping = {
                        '<OPEN>': 'open',
                        '<HIGH>': 'high', 
                        '<LOW>': 'low',
                        '<CLOSE>': 'close',
                        '<VOL>': 'volume'
                    }
                    df = df.rename(columns=column_mapping)
                    
                    # Оставляем только нужные колонки
                    df = df[['open', 'high', 'low', 'close', 'volume']]
                    
                    self.log(f"✅ Успешно созданы временные метки из <DATE> и <TIME>")
                    self.log(f"   Диапазон данных: {df.index.min()} - {df.index.max()}")
                    return df
                    
                except Exception as e:
                    self.log(f"❌ Ошибка создания временных меток: {e}")
                    return pd.DataFrame()
                    
            else:  # txt format
                # TXT формат: DATE, TIME, OPEN, etc. (без скобок)
                df = pd.read_csv(file_path, sep='\t')
                self.log(f"   Прочитано {len(df)} строк (TXT формат)")
                
                # Проверяем колонки TXT формата
                if 'DATE' not in df.columns or 'TIME' not in df.columns:
                    self.log(f"❌ TXT файл не содержит колонок DATE и TIME")
                    self.log(f"   Найденные колонки: {list(df.columns)}")
                    return pd.DataFrame()
                
                # Создаем временную метку из DATE и TIME
                try:
                    # Преобразуем числовые колонки в строки с заполнением нулями
                    date_str = df['DATE'].astype(str).str.zfill(8)
                    time_str = df['TIME'].astype(str).str.zfill(6)
                    
                    # Создаем временную метку
                    df['timestamp'] = pd.to_datetime(date_str + ' ' + time_str, format='%Y%m%d %H%M%S')
                    
                    # Устанавливаем временную метку как индекс
                    df.set_index('timestamp', inplace=True)
                    
                    # Оставляем только нужные колонки
                    df = df[['OPEN', 'HIGH', 'LOW', 'CLOSE', 'VOL']]
                    
                    # Переименовываем в нижний регистр для единообразия
                    df = df.rename(columns={
                        'OPEN': 'open',
                        'HIGH': 'high',
                        'LOW': 'low', 
                        'CLOSE': 'close',
                        'VOL': 'volume'
                    })
                    
                    self.log(f"✅ Успешно созданы временные метки из DATE и TIME")
                    self.log(f"   Диапазон данных: {df.index.min()} - {df.index.max()}")
                    return df
                    
                except Exception as e:
                    self.log(f"❌ Ошибка создания временных меток: {e}")
                    return pd.DataFrame()
            
        except Exception as e:
            self.log(f"❌ Ошибка чтения файла {file_path}: {str(e)}")
            return pd.DataFrame()

    def save_to_file(self, df, filename, file_format):
        """Сохранение данных в файл"""
        if df.empty:
            self.log(f"⚠️ Пустые данные для {filename}")
            return
            
        try:
            with open(filename, 'w', encoding='utf-8') as f:
                if file_format == 'csv':
                    # CSV формат с угловыми скобками
                    f.write("<DATE>,<TIME>,<OPEN>,<HIGH>,<LOW>,<CLOSE>,<VOL>\n")
                    for _, row in df.iterrows():
                        line = (f"{row['DATE']},{row['TIME']},{row['OPEN']:.7f},{row['HIGH']:.7f},"
                               f"{row['LOW']:.7f},{row['CLOSE']:.7f},{row['VOL']:.0f}\n")
                        f.write(line)
                else:
                    # TXT формат без скобок
                    f.write("DATE\tTIME\tOPEN\tHIGH\tLOW\tCLOSE\tVOL\n")
                    for _, row in df.iterrows():
                        line = (f"{row['DATE']}\t{row['TIME']}\t{row['OPEN']:.7f}\t{row['HIGH']:.7f}\t"
                               f"{row['LOW']:.7f}\t{row['CLOSE']:.7f}\t{row['VOL']:.0f}\n")
                        f.write(line)
            self.log(f"💾 Успешно сохранено: {filename} ({len(df)} строк)")
        except Exception as e:
            self.log(f"❌ Ошибка сохранения {filename}: {str(e)}")

if __name__ == "__main__":
    root = tk.Tk()
    app = MoexDataDownloader(root)
    root.mainloop()


Если буду развивать дальше, буду писать тут: t.me/GeorgyBlog

СЛ поубивал форматирование. Вот альтернативная ссылка на код (живет 24 часа):
ctxt.io/2/AAD4dGBOEw

Upd2: оказывается, СЛ умеет и код вставлять с подсветкой синтаксиса. Круто!

Или будет вечно висеть в посте в ТГ: t.me/c/2757188967/5626
Если будут обновления — в этом ТГ посте будут ссылки на новые версии.

Upd: работа под виндой тоже подтверждена одним человеком.

Upd3: новая версия:

Версия 1.1
Появился 30м таймфрейм.
Окно «Обновление данных» значительно шустрее.

t.me/c/2757188967/5648

7.1К | ★37
48 комментариев
А изначально задача в чём заключалась? Просто по списку тикеров выгрузить свечи для определенного таймфрейма? Проще было lua для quik.
avatar
Михаил Михалёв, у меня нет QUIK и подход через питон вроде универсальный — кроме интернета ничего не требуется.
LUA я вообще никогда в глаза не видел, разве что когда игры какие-то в детстве ковырял со скриптами на ней :)

А задача — да, именно такая была — иметь котировки и способ их обновления. Ну и вторичная — мне как идея понравился эксперимент написать/нагенерировать с помощью ИИ что-то на питоне с графическим UI, раньше такого не делал.
avatar
XXX★, А, для обучения, — норм:)
avatar
XXX★, если не трудно — данные откуда забираются? С сайта биржи?
avatar
tradeformation, да, как и написано в посте в разделе Документация :) Котрую никто никогда не читает:

Пример вызова:
Конкретный API вызов:url = «iss.moex.com/iss/engines/stock/markets/shares/securities/GAZP/candles.json»
params = {
'from': '2024-01-15 09:30:00',
'till': '2024-01-15 18:45:00',
'interval': 1,
'iss.meta': 'off'}

Вот так: iss.moex.com/iss/engines/stock/markets/shares/securities/GAZP/candles.json?from=2024-01-15%2009:30:00&till=2024-01-15%2018:45:00&interval=1&iss.meta=off
avatar
XXX★, неожиданно несколько. Я думал они данные только за деньги отдают. Есть одна идейка. Чтобы проверить нужно кучу данных выкачать. Надо бы присмотреться к этой возможности.
avatar
Пользовательские формы зачетные. Респектос за пост 👍
avatar
аригатошки. надо заценить.
avatar
а со сплитами как себя ведет? Надо вручную как-то корректировать?
и переносы торговых дней, например суббота рабочая — понедельник нерабочий ?
avatar
IliaM, 
а со сплитами как себя ведет?

Никакой логики не вводил для этого. Берет тупо информацию о ценах дневных свечей через API мосбиржи.

переносы торговых дней, например суббота рабочая — понедельник нерабочий ?

Тоже никак сейчас, там стоит условие номер дня > 5 значит выходной.
Тут надо глянуть предоставляет ли API биржи информацию о подобных днях, а если нет — то использовать рабочий календарь. Возможно и предоставляет, я не смотрел.

Опция качать только данные по будням (пн-пт) в наличии, но это все что есть пока.
avatar
XXX★, 
Берет тупо информацию о ценах дневных свечей через API мосбиржи.
Какие хорошие данные для теханализа ВТБ получаются, 
Но он же все в текстовый файл складывает? Т.е надо ручками подправить для своих нужд, если что.
avatar
IliaM, на выбор CSV или TXT. Ну можно и допилить под сплиты как то. Но мне кажется более логичным отдельное приложение для причесывания готовых заведомо чистых валидных данных в новый нужный вид.

Ну или сделать отдельную вкладку прямо в этом. Но я к тому, что это отдельно выделенный логический функционал должен быть, мне кажется. С отдельными названиями файлов свечей обработанных, в частности.
Конкретно для ВТБ, я бы еще их IPO бесконечные учитывал 
avatar
XXX★, 
Конкретно для ВТБ, я бы еще их IPO бесконечные учитывал 

тогда и free float надо привязывать. 

Может быть если лоты добавить в начальные данные, то можно упростить будет. Т.е вначале будут данные у ВТБ 10000 акций и цена 0,02, а потом перейдет в 1 акция и 70 рублей. Так вроде всё более гладко должно быть

avatar
Nikola Tesla, ну, как вариант — можно поставить виртуалку с линуксом :) Я так делал много лет, когда нужно было писать софт и проверять под линукс, а программировать хотелось на винде.

Вообще, как я понял, под виндой запустилась у одного человека (уточняю). Может там и нет проблем никаких. Пробовать надо.

Upd: работа под виндой тоже подтверждена одним человеком.
avatar
Nikola Tesla, работает и с win! Респект и уважение Автору поста… Потратил ночь для общего блага. и есть  нужный результат. Можно чуть допиливать… но это в итоге работает и в данном варианте!
avatar
Nikola Tesla, ну, насколько я вижу, это возможно — из питон скрипта под линуксом собрать exe, но выглядит несколько заморочно. У меня быстро не получилось сделать. Не делал так никогда. Проще все же питон скрипт запускать.
avatar
Уважуха! Жаль, 100 плюсов не поставить. Еще бы опцию вырезания котировок выходного дня…
avatar
Йоганн, А там вроде где то есть галка- только рабочие дни…
avatar
Йоганн, так есть галочка «качать данные только по будням». Это пн-пт будет только лить.
avatar

XXX★, 

работа под виндой тоже подтверждена одним человеком.


А как запускать под виндой, через интерпретатор Питона или надо загрузить фреймворк какой-то?

avatar
Йоганн, ну я на линуксе через консоль запускаю: python3 <имя скрипта>. На винде тоже же должен быть консольный питон, или как иначе? Вот я бы через него и запускал, больше то ничего не надо, зачем лишние зависимости?
avatar
XXX★, понял, спасибо)) А то я сильно поотстал от времени. Не знал, что питон уже встроен в винду, поищу, либо попробую скомпилировать в exe-файл
avatar
Йоганн, а я не знаю, встроен или нет. Думаю, что нет, но наверняка можно скачать.
avatar
А где есть фьючерсы склейка качественная? Хотя бы основные?
avatar
Александр Брут, рекомендую спросить у deepseek. Она отлично выдает готовые запросы к API мосбиржи на любые темы. Или вы имели ввиду котировки готовые? Этого мое приложение не умеет.
avatar
Респектище.
avatar
Данные по акциям скаченные и обновляемые давно лежат 
www.kaggle.com/datasets/olegshpagin/russia-stocks-prices-ohlcv
у чела они постоянно качаются и обновляются
Автор тыж погроммист, вроде, был, когда-то, выкладывать код постом — людей не уважать!
Для кода есть github или посконный gitflic.ru/
avatar
Beach Bunny, я когда вышел на пенсию — удалил свой github. Я теперь не программист ) Навык не поддерживаю, новинки православного С++ не отслеживаю, какой же это программист. Я теперь — лицо, имеющее склонность к кодингу :) 
avatar
Beach Bunny, да и вообще — зачем мне создавать трафик на какой то зарубежный гитхаб, если я могу хотя бы на зарубежный, но в нем все же в именно мой ТГ канал попытаться создать трафик, размещая дальнейший код только там?
avatar
XXX★, есть посконный gitflic.ru/ 

avatar
Бро, я закинул промт с ТЗ на загрузку списка бондов с moex на vba. И мне за 1 минуту накодил функций через json и все отработало. Честно, я ловлю челюсть от удивления под столом. Спасибо тебе добрый человек за идею.
avatar
yanyarman, незачт :) Некоторые недооценивают мощь ИИ. Не понимаю почему.
avatar
XXX★, два года назад или около того я кидал промт в gpt3. Ну такое себе. Ошибок было много, я бросил. Сейчас в gpt5 закинул ТЗ. Да я просто охренел от результата, бро! gpt еще мне таких вопросиков подкинул, о ля ля). На рабочей неделе прогоню старую писанину через code review. Посмотрим, может и закрою свои легаси проблемы.
avatar
Под виндой 11 работает.
   Для тех, кто не работает на питоне наверное следует подсказать, что кроме установки питона еще надо установить библиотеки pandas и requests. 
   А почему бы не сделать универсальную выгрузку — не только для акций, но и фьючерсов? Было бы совсем здорово. У меня на lua так реализовано, но конечно не так красиво — без оконного интерфейса, lua с ним не дружит.
Владимиров Владимир, я сейчас делаю для ОФЗ вкладку. Мб и до остальных инструментов дойду.
avatar
на Линуксе (Mint 21.3, Python 3.10) потребовалось доставлять tkinter (pandas ставил раньше). На винде (Win 10, Python 3.8) попросил доставить requests.
Все работает, только Касперский достал. Никак не доходят руки настроить его по уму.
avatar

Йоганн, NumPy вроде в последней стабильной версии питона автоматом есть. Хотя согласен с вашим дополнением. Чтобы не гадать есть она или нет, можно проверить так: 
           import numpy as np
           print(np.__version__) 
напечатается версия numpy, а если не установлена — выдаст ошибку. Я не на питоне пишу, у меня она уже была раньше. 

Владимиров Владимир, numpy содержится в pandas, как оказалось))
avatar

 Докладываю:

Установил на 10-ю винду Python 3.14  вместе с PIP

Установил pandas и requests.

Запустил — работает. Но почти все поле приложения находится за пределами экрана. Разрешение моего экрана 1366х768.

Изменил код:

self.root.title(«MOEX Data Downloader — Скачивание данных Московской биржи»)
self.root.geometry(«1366x768»)

# Центрируем окно на экране
self.root.update_idletasks()
x = (self.root.winfo_screenwidth() — 1366) // 2
y = (self.root.winfo_screenheight() — 768) // 2
self.root.geometry(f«1366x768+{x}+{y}»)

=========================
Может, имеет смысл сделать версию с адаптивным разрешением?

avatar
Йоганн, 
Может, имеет смысл сделать версию с адаптивным разрешением?

Я так понял, немного изучив вопрос, что это может быть несколько трудоемко. Я пока не готов этим заниматься. У меня в приоритете новый функционал пока что. Ссорян.
avatar
XXX★, так надо было UI делать на Qt6, неужели бывший C++ погроммист не знает про Qt ? 
И не было бы проблем с резайзом UI.
Ужос 
avatar
Win 10 — пашет
IDE для питона использую Spyder, все само поставило, вставил только скрипт и запустил.
Spyder качал с офф сайта: www.spyder-ide.org/
Если не пашет есть копия на гитхабе: github.com/spyder-ide/spyder
Виталий Курячий, уже выходили новые версии: smart-lab.ru/blog/1222391.php

Рекомендую 2.0 тк она прям значительно быстрее. А в следующих верисиях будет уже новый другой функционал, помимо загрузок котировок. М.б многим хватит и 2.0
avatar

Читайте на SMART-LAB:
Фото
IR-команда «Озон Фармацевтика» встретилась с аналитиком СберИнвестиций Софией Кирсановой
Мы поделились нашими планами и достижениями, а также ответили на вопросы. Поговорили о включении в индекс Мосбиржи, росте...
Фото
Норникель - в топ-10 акций портфеля ВТБ Инвестиции
💼На днях в рамках инвестиционного форума ВТБ «РОССИЯ ЗОВЕТ!» команда аналитиков ВТБ Инвестиции представила свою обновленную стратегию на рынках...
Фото
Объем коммерческих медицинских услуг может достигнуть 4,27 трлн руб. к 2030 году
Оборот рынка коммерческой медицины в 2025 году составит 2,32 трлн руб., прибавив 15,2% год к году, подсчитали в аудиторско-консалтинговой компании...

теги блога XXX★

....все тэги



UPDONW
Новый дизайн