"""Market data collection worker."""

import math
import random
import webbrowser
from asyncio import Lock, gather, sleep
from collections import deque
from datetime import UTC, datetime, timedelta
from logging import getLogger
from pathlib import Path
from typing import Any

import aiofiles.os
import aioshutil
import numpy as np
import pandas as pd
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from PySide6.QtWidgets import QMenu

from solie.common import UniqueTask, outsource, spawn, spawn_blocking
from solie.logic import (
    DownloadPreset,
    DownloadUnitSize,
    download_aggtrade_csv,
    fill_holes_with_aggtrades,
    process_aggtrade_csv,
)
from solie.overlay import (
    DonationGuide,
    DownloadFillOption,
    DownloadFillOptionChooser,
    DownloadYearRange,
)
from solie.utility import (
    MAX_REQUEST_RETRIES,
    PROGRESS_BAR_MAX,
    AggregateTrade,
    ApiRequester,
    ApiStreamer,
    BookTicker,
    Cell,
    DurationRecorder,
    Implements,
    MarkPrice,
    RWLock,
    combine_candle_data,
    create_empty_candle_data,
    format_numeric,
    internet_connected,
    slice_deque,
    sort_data_frame,
    to_moment,
    when_internet_disconnected,
)
from solie.widget import overlay
from solie.window import Window

from .united import Worker, team

logger = getLogger(__name__)


lambda: Implements[Worker](Collector)


class Collector:
    """Worker for collecting market data from Binance."""

    def __init__(self, window: Window, scheduler: AsyncIOScheduler) -> None:
        """Initialize market data collector."""
        self._window = window
        self._scheduler = scheduler
        self._workerpath = window.datapath / "collector"

        self._price_precisions: dict[str, int] = {}  # Symbol and decimal places
        self._markets_gone = set[str]()  # Symbols

        self._download_fill_task = UniqueTask()

        self._api_requester = ApiRequester()

        self.aggtrade_candle_sizes: dict[str, int] = {}
        for symbol in window.data_settings.target_symbols:
            self.aggtrade_candle_sizes[symbol] = 0

        # Candle data.
        # It's expected to have only the data of current year,
        # while data of previous years are stored in the disk.
        self.candle_data = RWLock[pd.DataFrame](
            create_empty_candle_data(window.data_settings.target_symbols),
        )

        # Realtime data
        self.realtime_data = deque[BookTicker | MarkPrice]([], 2 ** (10 + 10 + 2))
        self.aggregate_trades = deque[AggregateTrade]([], 2 ** (10 + 10))

        self._scheduler.add_job(
            self._display_status_information,
            trigger="cron",
            second="*",
        )
        self._scheduler.add_job(
            self._fill_candle_data_holes,
            trigger="cron",
            second="*/10",
        )
        self._scheduler.add_job(
            self._add_candle_data,
            trigger="cron",
            second="*/10",
        )
        self._scheduler.add_job(
            self._organize_data,
            trigger="cron",
            minute="*",
        )
        self._scheduler.add_job(
            self.get_exchange_information,
            trigger="cron",
            minute="*",
        )
        self._scheduler.add_job(
            self._save_candle_data,
            trigger="cron",
            hour="*",
        )

        self._mark_price_streamer = ApiStreamer(
            "wss://fstream.binance.com/ws/!markPrice@arr@1s",
            self._add_mark_price,
        )

        self._book_ticker_streamers = [
            ApiStreamer(
                f"wss://fstream.binance.com/ws/{s.lower()}@bookTicker",
                self._add_book_tickers,
            )
            for s in self._window.data_settings.target_symbols
        ]
        self._aggtrade_streamers = [
            ApiStreamer(
                f"wss://fstream.binance.com/ws/{s.lower()}@aggTrade",
                self._add_aggregate_trades,
            )
            for s in self._window.data_settings.target_symbols
        ]

        when_internet_disconnected(self._clear_aggregate_trades)

        self._connect_ui_events()

    def _connect_ui_events(self) -> None:
        window = self._window

        job = self._guide_donation
        outsource(window.pushButton_9.clicked, job)
        job = self._download_fill_candle_data
        outsource(window.pushButton_2.clicked, job)

        action_menu = QMenu(window)
        window.pushButton_13.setMenu(action_menu)

        text = "Open historical data webpage of Binance"
        job = self._open_binance_data_page
        new_action = action_menu.addAction(text)
        outsource(new_action.triggered, job)
        text = "Stop filling candle data"
        job = self._stop_filling_candle_data
        new_action = action_menu.addAction(text)
        outsource(new_action.triggered, job)

    async def load_work(self) -> None:
        """Load saved candle data from disk."""
        await aiofiles.os.makedirs(self._workerpath, exist_ok=True)

        current_year = datetime.now(UTC).year
        async with self.candle_data.write_lock as cell:
            filepath = self._workerpath / f"candle_data_{current_year}.pickle"
            if await aiofiles.os.path.isfile(filepath):
                df: pd.DataFrame = await spawn_blocking(pd.read_pickle, filepath)
                if not df.index.is_monotonic_increasing:
                    df = await spawn_blocking(sort_data_frame, df)
                cell.data = df

    async def dump_work(self) -> None:
        """Save candle data to disk."""
        await self._save_candle_data()

    async def _organize_data(self) -> None:
        duration_recorder = DurationRecorder("ORGANIZE_COLLECTOR_DATA")

        async with self.candle_data.write_lock as cell:
            original_index = cell.data.index
            if not cell.data.index.is_unique:
                unique_index = original_index.drop_duplicates()
                cell.data = cell.data.reindex(unique_index)
            if not cell.data.index.is_monotonic_increasing:
                cell.data = await spawn_blocking(sort_data_frame, cell.data)

        duration_recorder.record()

    async def _save_candle_data(self) -> None:
        current_year = datetime.now(UTC).year
        filepath = self._workerpath / f"candle_data_{current_year}.pickle"
        filepath_new = self._workerpath / f"candle_data_{current_year}.pickle.new"
        filepath_backup = self._workerpath / f"candle_data_{current_year}.pickle.backup"

        async with self.candle_data.read_lock as cell:
            mask = cell.data.index.year == current_year  # type:ignore
            year_df: pd.DataFrame = cell.data[mask].copy()

        await spawn_blocking(year_df.to_pickle, filepath_new)

        if await aiofiles.os.path.isfile(filepath_backup):
            await aiofiles.os.remove(filepath_backup)
        if await aiofiles.os.path.isfile(filepath):
            await aiofiles.os.rename(filepath, filepath_backup)
        if await aiofiles.os.path.isfile(filepath_new):
            await aiofiles.os.rename(filepath_new, filepath)

    async def get_exchange_information(self) -> None:
        """Fetch exchange information from Binance."""
        if not internet_connected():
            return

        payload: dict[str, Any] = {}
        response = await self._api_requester.binance(
            http_method="GET",
            path="/fapi/v1/exchangeInfo",
            payload=payload,
        )
        about_exchange = response

        for about_symbol in about_exchange["symbols"]:
            symbol = about_symbol["symbol"]

            about_filter: dict[str, Any] = {}
            for symbol_filter in about_symbol["filters"]:
                if symbol_filter["filterType"] == "PRICE_FILTER":
                    about_filter = symbol_filter
                    break

            ticksize = float(about_filter["tickSize"])
            price_precision = int(math.log10(1 / ticksize))
            self._price_precisions[symbol] = price_precision

    async def _fill_candle_data_holes(self) -> None:
        """Fill holes in candle data by fetching missing data."""
        if not internet_connected():
            return

        current_moment = to_moment(datetime.now(UTC))
        split_moment = current_moment - timedelta(days=2)
        target_symbols = self._window.data_settings.target_symbols

        # only the recent part
        async with self.candle_data.read_lock as cell:
            recent_candle_data = cell.data[cell.data.index >= split_moment].copy()

        did_fill = await self._find_full_symbols(
            recent_candle_data,
            current_moment,
            target_symbols,
        )

        if not did_fill:
            return

        await self._merge_filled_data(recent_candle_data, split_moment)

    async def _find_full_symbols(
        self,
        recent_candle_data: pd.DataFrame,
        current_moment: datetime,
        target_symbols: list[str],
    ) -> bool:
        """Find and fill holes in candle data for symbols."""
        full_symbols: set[str] = set()
        request_count = 0
        needed_moments = int((86400 - 60) / 10) + 1
        did_fill = False

        while (
            len(full_symbols) < len(target_symbols)
            and request_count < MAX_REQUEST_RETRIES
        ):
            for symbol in target_symbols:
                if symbol in full_symbols:
                    continue

                filled = await self._fill_symbol_holes(
                    symbol,
                    recent_candle_data,
                    current_moment,
                    needed_moments,
                )
                if filled is None:
                    # Symbol is complete
                    full_symbols.add(symbol)
                elif filled:
                    # Successfully filled holes
                    did_fill = True

                request_count += 1

        return did_fill

    async def _fill_symbol_holes(
        self,
        symbol: str,
        recent_candle_data: pd.DataFrame,
        current_moment: datetime,
        needed_moments: int,
    ) -> bool | None:
        """Fill holes for a specific symbol.

        Returns None if complete, True if filled, False if failed.
        """
        from_moment = current_moment - timedelta(hours=24)
        until_moment = current_moment - timedelta(minutes=1)

        # Check current data completeness
        columns = [str(s) for s in recent_candle_data.columns]
        chosen_columns = [s for s in columns if s.startswith(symbol)]
        inspect_df: pd.DataFrame = recent_candle_data[chosen_columns][
            from_moment:until_moment
        ]
        base_index = inspect_df.dropna().index
        temp_sr = pd.Series(0, index=base_index)
        written_moments = len(temp_sr)

        if written_moments == needed_moments:
            return None  # Symbol is complete

        # Find gaps
        if from_moment not in temp_sr.index:
            temp_sr[from_moment] = np.nan
        if until_moment not in temp_sr.index:
            temp_sr[until_moment] = np.nan
        temp_sr = await spawn_blocking(temp_sr.asfreq, "10s")
        isnan_sr = await spawn_blocking(temp_sr.isna)
        nan_index = isnan_sr[isnan_sr == 1].index

        if len(nan_index) == 0:
            return None

        moment_to_fill_from: datetime = nan_index[0]

        # Fetch aggregate trades to fill gaps
        aggtrades = await self._fetch_aggtrades_for_gap(symbol, moment_to_fill_from)

        if aggtrades is None:
            return False  # Failed to fetch

        # Fill the gaps
        last_fetched_id = max(aggtrades.keys())
        last_fetched_time = datetime.fromtimestamp(
            aggtrades[last_fetched_id].timestamp / 1000,
            tz=UTC,
        )

        await spawn_blocking(
            fill_holes_with_aggtrades,
            symbol,
            recent_candle_data,
            aggtrades,
            moment_to_fill_from,
            last_fetched_time,
        )
        return True

    async def _fetch_aggtrades_for_gap(
        self,
        symbol: str,
        moment_to_fill_from: datetime,
    ) -> dict[int, AggregateTrade] | None:
        """Fetch aggregate trades to fill a data gap."""
        aggtrades: dict[int, AggregateTrade] = {}
        last_fetched_time = moment_to_fill_from

        while last_fetched_time < moment_to_fill_from + timedelta(seconds=10):
            # intend to fill at least one 10 second candle bar
            payload = {
                "symbol": symbol,
                "startTime": int(last_fetched_time.timestamp() * 1000),
                "limit": 1000,
            }
            response = await self._api_requester.binance(
                http_method="GET",
                path="/fapi/v1/aggTrades",
                payload=payload,
            )

            if len(response) == 0:
                self._markets_gone.add(symbol)
                return None

            for about_aggtrade in response:
                aggtrade_id = int(about_aggtrade["a"])
                aggtrade = AggregateTrade(
                    timestamp=about_aggtrade["T"],
                    symbol=symbol,
                    price=float(about_aggtrade["p"]),
                    volume=float(about_aggtrade["q"]),
                )
                aggtrades[aggtrade_id] = aggtrade

            last_fetched_id = max(aggtrades.keys())
            last_fetched_time = datetime.fromtimestamp(
                aggtrades[last_fetched_id].timestamp / 1000,
                tz=UTC,
            )

        return aggtrades

    async def _merge_filled_data(
        self,
        recent_candle_data: pd.DataFrame,
        split_moment: datetime,
    ) -> None:
        """Merge filled candle data back into main dataframe."""
        async with self.candle_data.write_lock as cell:
            original_candle_data = cell.data[cell.data.index < split_moment]
            # in case the other data is added during the task
            # read the data again
            temp_df = cell.data[cell.data.index >= split_moment]
            recent_candle_data = recent_candle_data.combine_first(temp_df)
            if not recent_candle_data.index.is_monotonic_increasing:
                recent_candle_data = await spawn_blocking(
                    sort_data_frame,
                    recent_candle_data,
                )
            candle_data = pd.concat([original_candle_data, recent_candle_data])
            cell.data = candle_data

    async def _display_status_information(self) -> None:
        async with self.candle_data.read_lock as cell:
            if len(cell.data) == 0:
                return

        if len(self._price_precisions) == 0:
            return

        price_precisions = self._price_precisions
        recent_aggregate_trades = slice_deque(self.aggregate_trades, 2 ** (10 + 6))
        for symbol in self._window.data_settings.target_symbols:
            latest_price: float | None = None
            for aggregate_trade in reversed(recent_aggregate_trades):
                if aggregate_trade.symbol == symbol:
                    latest_price = aggregate_trade.price
                    break
            if latest_price is None:
                text = "Unavailable"
            else:
                price_precision = price_precisions[symbol]
                text = f"${latest_price:.{price_precision}f}"
            self._window.price_labels[symbol].setText(text)

        if len(self._markets_gone) == 0:
            cumulation_rate = await self.check_candle_data_cumulation_rate()
            first_written_time = None
            last_written_time = None
            realtime_data = self.realtime_data
            if len(realtime_data) > 0:
                if first_written_time is None:
                    first_record = realtime_data[0]
                    first_written_time = first_record.timestamp
                last_record = realtime_data[-1]
                last_written_time = last_record.timestamp
                written_seconds = (last_written_time - first_written_time) / 10**3
            else:
                written_seconds = 0.0
            written_length = timedelta(seconds=written_seconds)
            range_days = written_length.days
            range_hours, remains = divmod(written_length.seconds, 3600)
            range_minutes, remains = divmod(remains, 60)
            written_length_text = f"{range_days}d {range_hours}h {range_minutes}m"

            text = ""
            text += f"24h candle data accumulation rate {cumulation_rate * 100:.2f}%"
            text += "  ⦁  "
            text += f"Realtime data length {written_length_text}"
        else:
            markets_gone = self._markets_gone
            text = (
                f"It seems that {', '.join(markets_gone)} markets are "
                "removed by Binance. You should make a new data folder."
            )

        self._window.label_6.setText(text)

    async def check_candle_data_cumulation_rate(self) -> float:
        """Calculate percentage of collected candle data."""
        current_moment = to_moment(datetime.now(UTC))
        count_end_moment = current_moment - timedelta(seconds=10)
        count_start_moment = count_end_moment - timedelta(hours=24)

        count_end_moment -= timedelta(seconds=1)

        async with self.candle_data.read_lock as cell:
            cumulated = len(cell.data[count_start_moment:count_end_moment].dropna())
        needed_moments = 6 * 60 * 24
        return cumulated / needed_moments

    async def wait_for_candle_data_ready(self, before_moment: datetime) -> bool:
        """Wait for candle data to be ready up to the specified moment.

        Returns True when data is ready, False on timeout.
        """
        for _ in range(50):
            async with self.candle_data.read_lock as cell:
                df_index: pd.DatetimeIndex = cell.data.index  # type:ignore
                last_index = df_index[-1].to_pydatetime()
                if last_index == before_moment:
                    return True
            await sleep(0.1)
        return False

    async def _open_binance_data_page(self) -> None:
        await spawn_blocking(webbrowser.open, "https://www.binance.com/en/landing/data")

    async def _download_fill_candle_data(self) -> None:
        fill_option = await overlay(DownloadFillOptionChooser())
        if fill_option is None:
            return
        unique_task = self._download_fill_task
        unique_task.spawn(
            self._download_fill_candle_data_real(unique_task, fill_option),
        )

    def _create_download_presets(
        self,
        fill_option: DownloadYearRange | DownloadFillOption,
    ) -> list[DownloadPreset]:
        download_presets: list[DownloadPreset] = []
        target_symbols = self._window.data_settings.target_symbols
        match fill_option:
            case DownloadYearRange(start=year_from, end=year_to):
                for year in range(year_from, year_to + 1):
                    for month in range(1, 12 + 1):
                        download_presets.extend(
                            DownloadPreset(
                                symbol=s,
                                unit_size=DownloadUnitSize.MONTHLY,
                                year=year,
                                month=month,
                            )
                            for s in target_symbols
                        )
            case DownloadFillOption.FROM_YEAR_START_TO_LAST_MONTH:
                now = datetime.now(UTC)
                current_year = now.year
                current_month = now.month
                for month in range(1, current_month):
                    download_presets.extend(
                        DownloadPreset(
                            symbol=s,
                            unit_size=DownloadUnitSize.MONTHLY,
                            year=current_year,
                            month=month,
                        )
                        for s in target_symbols
                    )
            case DownloadFillOption.THIS_MONTH:
                now = datetime.now(UTC)
                current_year = now.year
                current_month = now.month
                current_day = now.day
                for target_day in range(1, current_day):
                    download_presets.extend(
                        DownloadPreset(
                            symbol=s,
                            unit_size=DownloadUnitSize.DAILY,
                            year=current_year,
                            month=current_month,
                            day=target_day,
                        )
                        for s in target_symbols
                    )
            case DownloadFillOption.LAST_TWO_DAYS:
                now = datetime.now(UTC)
                yesterday = now - timedelta(hours=24)
                day_before_yesterday = yesterday - timedelta(hours=24)
                download_presets.extend(
                    DownloadPreset(
                        symbol=s,
                        unit_size=DownloadUnitSize.DAILY,
                        year=day_before_yesterday.year,
                        month=day_before_yesterday.month,
                        day=day_before_yesterday.day,
                    )
                    for s in target_symbols
                )
                download_presets.extend(
                    DownloadPreset(
                        symbol=s,
                        unit_size=DownloadUnitSize.DAILY,
                        year=yesterday.year,
                        month=yesterday.month,
                        day=yesterday.day,
                    )
                    for s in target_symbols
                )

        return download_presets

    async def _download_fill_candle_data_real(
        self,
        unique_task: UniqueTask,
        fill_option: DownloadYearRange | DownloadFillOption,
    ) -> None:
        download_presets = self._create_download_presets(fill_option)
        random.shuffle(download_presets)

        total_steps = len(download_presets) * 2
        done_steps = Cell(0)

        async def play_progress_bar() -> None:
            while True:
                if done_steps.value == total_steps:
                    progressbar_value = self._window.progressBar_3.value()
                    if progressbar_value == PROGRESS_BAR_MAX:
                        await sleep(0.1)
                        self._window.progressBar_3.setValue(0)
                        return
                before_value = self._window.progressBar_3.value()
                if before_value < PROGRESS_BAR_MAX:
                    remaining = (
                        math.ceil(1000 / total_steps * done_steps.value) - before_value
                    )
                    new_value = before_value + math.ceil(remaining * 0.2)
                    self._window.progressBar_3.setValue(new_value)
                await sleep(0.01)

        bar_task = spawn(play_progress_bar())
        bar_task.add_done_callback(lambda _: self._window.progressBar_3.setValue(0))
        unique_task.add_done_callback(lambda _: bar_task.cancel())

        current_year = datetime.now(UTC).year
        classified_download_presets = self._classify_presets_by_year(download_presets)

        for preset_year, download_presets in classified_download_presets.items():
            combined_df = await self._download_and_combine_presets(
                preset_year,
                download_presets,
                done_steps,
            )
            if combined_df is not None:
                await self._save_or_merge_downloaded_data(
                    preset_year,
                    current_year,
                    combined_df,
                )

    def _classify_presets_by_year(
        self,
        download_presets: list[DownloadPreset],
    ) -> dict[int, list[DownloadPreset]]:
        """Classify download presets by year."""
        all_years = sorted({t.year for t in download_presets})
        classified_download_presets: dict[int, list[DownloadPreset]] = {
            y: [] for y in all_years
        }
        for download_preset in download_presets:
            classified_download_presets[download_preset.year].append(download_preset)
        return classified_download_presets

    async def _download_and_combine_presets(
        self,
        preset_year: int,
        download_presets: list[DownloadPreset],
        done_steps: Cell[int],
    ) -> pd.DataFrame | None:
        """Download CSV files for presets and combine them."""
        download_dir = self._workerpath / f"downloaded_csv_{preset_year}"
        await aioshutil.rmtree(download_dir, ignore_errors=True)
        await aiofiles.os.makedirs(download_dir, exist_ok=True)

        downloaded_dfs: list[pd.DataFrame] = []

        async def download_fill(
            download_preset: DownloadPreset,
            download_lock: Lock,
            download_dir: Path,
            downloaded_dfs: list[pd.DataFrame],
        ) -> None:
            async with download_lock:
                zip_file_path = await download_aggtrade_csv(
                    download_preset,
                    download_dir,
                )
            done_steps.value += 1

            if zip_file_path is not None:
                downloaded_df = await spawn_blocking(
                    process_aggtrade_csv,
                    download_preset,
                    zip_file_path,
                )
                if downloaded_df is not None:
                    downloaded_dfs.append(downloaded_df)
            done_steps.value += 1

        download_lock = Lock()
        coros = (
            download_fill(p, download_lock, download_dir, downloaded_dfs)
            for p in download_presets
        )
        await gather(*coros)

        await aioshutil.rmtree(download_dir, ignore_errors=True)

        if len(downloaded_dfs) > 0:
            return await spawn_blocking(combine_candle_data, downloaded_dfs)
        logger.info("No data downloaded for the year %d", preset_year)
        return None

    async def _save_or_merge_downloaded_data(
        self,
        preset_year: int,
        current_year: int,
        combined_df: pd.DataFrame,
    ) -> None:
        """Save downloaded data to disk or merge with current data."""
        if preset_year < current_year:
            await spawn_blocking(
                combined_df.to_pickle,
                self._workerpath / f"candle_data_{preset_year}.pickle",
            )
            logger.info("Saved candle data of year %d to disk", preset_year)
        else:
            async with self.candle_data.write_lock as cell_worker:
                cell_worker.data = await spawn_blocking(
                    combine_candle_data,
                    [combined_df, cell_worker.data],
                )
            await self._save_candle_data()
            logger.info("Filled the candle data with the downloaded history data")

        spawn(team.transactor.display_lines())
        spawn(team.simulator.display_lines())
        spawn(team.simulator.display_available_years())

    async def _add_book_tickers(self, received: dict[str, Any]) -> None:
        duration_recorder = DurationRecorder("ADD_BOOK_TICKERS")

        symbol = received["s"]
        best_bid = float(received["b"])
        best_ask = float(received["a"])
        event_time = received["E"]  # In milliseconds

        book_ticker = BookTicker(
            timestamp=event_time,
            symbol=symbol,
            best_bid_price=best_bid,
            best_ask_price=best_ask,
        )
        self.realtime_data.append(book_ticker)

        duration_recorder.record()

    async def _add_mark_price(self, received: list[dict[str, Any]]) -> None:
        duration_recorder = DurationRecorder("ADD_MARK_PRICE")

        target_symbols = self._window.data_settings.target_symbols
        event_time = received[0]["E"]  # In milliseconds
        for about_mark_price in received:
            symbol = about_mark_price["s"]
            if symbol in target_symbols:
                mark_price = float(about_mark_price["p"])
                mark_price = MarkPrice(
                    timestamp=event_time,
                    symbol=symbol,
                    mark_price=mark_price,
                )
                self.realtime_data.append(mark_price)

        duration_recorder.record()

    async def _add_aggregate_trades(self, received: dict[str, Any]) -> None:
        duration_recorder = DurationRecorder("ADD_AGGREGATE_TRADES")

        symbol = received["s"]
        price = float(received["p"])
        volume = float(received["q"])
        trade_time = received["T"]  # In milliseconds

        aggregate_trade = AggregateTrade(
            timestamp=trade_time,
            symbol=symbol,
            price=price,
            volume=volume,
        )
        self.aggregate_trades.append(aggregate_trade)

        duration_recorder.record()

    async def _clear_aggregate_trades(self) -> None:
        self.realtime_data.clear()

    async def _add_candle_data(self) -> None:
        duration_recorder = DurationRecorder("ADD_CANDLE_DATA")

        current_moment = to_moment(datetime.now(UTC))
        before_moment = current_moment - timedelta(seconds=10.0)
        collect_from = int(before_moment.timestamp()) * 1000
        collect_to = int(current_moment.timestamp()) * 1000
        aggregate_trades = self.aggregate_trades

        # Ensure that the data have been watched for long enough.
        first_received_index = aggregate_trades[0].timestamp
        if collect_from <= first_received_index:
            return

        # Collect trades that should be included in the candle.
        collected_aggregate_trades: list[AggregateTrade] = []
        for aggregate_trade in reversed(aggregate_trades):
            if aggregate_trade.timestamp < collect_from - 1000:
                # Go additional 1000 milliseconds backward.
                break
            collected_aggregate_trades.append(aggregate_trade)
        if len(collected_aggregate_trades) == 0:
            return
        collected_aggregate_trades.reverse()  # Sort by time
        collected_aggregate_trades = [
            t
            for t in collected_aggregate_trades
            if collect_from < t.timestamp < collect_to
        ]

        new_values: dict[str, float] = {}
        for symbol in self._window.data_settings.target_symbols:
            symbol_aggregate_trades = [
                t for t in collected_aggregate_trades if t.symbol == symbol
            ]
            self.aggtrade_candle_sizes[symbol] = len(symbol_aggregate_trades)

            if len(symbol_aggregate_trades) > 0:
                open_price = symbol_aggregate_trades[0].price
                high_price = max([t.price for t in symbol_aggregate_trades])
                low_price = min([t.price for t in symbol_aggregate_trades])
                close_price = symbol_aggregate_trades[-1].price
                sum_volume = sum([t.volume for t in symbol_aggregate_trades])
            else:
                async with self.candle_data.read_lock as cell:
                    inspect_sr = cell.data.iloc[-60:][f"{symbol}/CLOSE"].copy()
                inspect_sr = inspect_sr.dropna()
                if len(inspect_sr) == 0:
                    return
                last_price = inspect_sr.tolist()[-1]
                open_price = last_price
                high_price = last_price
                low_price = last_price
                close_price = last_price
                sum_volume = 0.0

            new_values[f"{symbol}/OPEN"] = open_price
            new_values[f"{symbol}/HIGH"] = high_price
            new_values[f"{symbol}/LOW"] = low_price
            new_values[f"{symbol}/CLOSE"] = close_price
            new_values[f"{symbol}/VOLUME"] = sum_volume

        async with self.candle_data.write_lock as cell:
            for column_name, new_data_value in new_values.items():
                cell.data.loc[before_moment, column_name] = np.float32(new_data_value)
            if not cell.data.index.is_monotonic_increasing:
                cell.data = await spawn_blocking(sort_data_frame, cell.data)

        duration_recorder.record()

    async def _stop_filling_candle_data(self) -> None:
        self._download_fill_task.cancel()

    async def _guide_donation(self) -> None:
        await overlay(DonationGuide())

    async def check_saved_years(self) -> list[int]:
        """Get list of years with saved candle data."""
        return [
            int(format_numeric(filename))
            for filename in await aiofiles.os.listdir(self._workerpath)
            if filename.startswith("candle_data_") and filename.endswith(".pickle")
        ]

    async def read_saved_candle_data(self, year: int) -> pd.DataFrame:
        """Read saved candle data for specific year."""
        filepath = self._workerpath / f"candle_data_{year}.pickle"
        candle_data: pd.DataFrame = await spawn_blocking(pd.read_pickle, filepath)
        return candle_data
