Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ temp_config.json
*.ods
*.c
*.h
.DS_Store

# OctoBot logs
logs
Expand Down
4 changes: 4 additions & 0 deletions packages/backtesting/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 2026-02-20
### Added
[Comparators] Add find_matching_data_file to compare backtesting data files

## [1.10.0] - 2026-01-04
### Updated
[Requirements] Bump OctoBot-Commons version
Expand Down
5 changes: 5 additions & 0 deletions packages/backtesting/octobot_backtesting/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from octobot_backtesting.api import backtesting
from octobot_backtesting.api import exchange_data_collector
from octobot_backtesting.api import social_data_collector
from octobot_backtesting.api import data_comparator

from octobot_backtesting.api.data_file_converters import (
convert_data_file,
Expand Down Expand Up @@ -77,6 +78,9 @@
social_historical_data_collector_factory,
social_live_data_collector_factory,
)
from octobot_backtesting.api.data_comparator import (
find_matching_data_file,
)

__all__ = [
"convert_data_file",
Expand Down Expand Up @@ -123,4 +127,5 @@
"is_data_collector_finished",
"social_historical_data_collector_factory",
"social_live_data_collector_factory",
"find_matching_data_file",
]
21 changes: 21 additions & 0 deletions packages/backtesting/octobot_backtesting/api/data_comparator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Drakkar-Software OctoBot-Backtesting
# Copyright (c) Drakkar-Software, All rights reserved.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3.0 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library.
import octobot_backtesting.comparators as comparators
import octobot_backtesting.constants as constants


async def find_matching_data_file(data_path=constants.BACKTESTING_FILE_PATH, **kwargs) -> str | None:
return await comparators.DataComparator(data_path).find_matching_data_file(**kwargs)
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Drakkar-Software OctoBot-Backtesting
# Copyright (c) Drakkar-Software, All rights reserved.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3.0 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library.
from octobot_backtesting.comparators import data_comparator
from octobot_backtesting.comparators.data_comparator import DataComparator

__all__ = [
"DataComparator",
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# Drakkar-Software OctoBot-Backtesting
# Copyright (c) Drakkar-Software, All rights reserved.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3.0 of the License, or (at your option) any later version.
#
# This library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library.
import os.path as path

import octobot_commons.logging as logging

import octobot_backtesting.constants as constants
import octobot_backtesting.data as data
import octobot_backtesting.enums as enums


class DataComparator:
def __init__(self, data_path=constants.BACKTESTING_FILE_PATH):
self.logger = logging.get_logger(self.__class__.__name__)
self.data_path = data_path

def _sorted_str_list(self, values) -> list:
if not values:
return []
return sorted(str(v) for v in values)

def _timestamps_match(self, existing_start, existing_end, requested_start, requested_end) -> bool:
req_start_s = int(requested_start / 1000) if requested_start else 0
req_end_s = int(requested_end / 1000) if requested_end else 0
# SQLite may return timestamps as strings; normalise to int before comparing
ex_start_s = int(existing_start) if existing_start else 0
ex_end_s = int(existing_end) if existing_end else 0
if req_start_s and ex_start_s != req_start_s:
return False
if req_end_s and ex_end_s != req_end_s:
return False
return True

def exchange_description_matches(self, description: dict,
exchange_name: str,
symbols: list,
time_frames: list,
start_timestamp,
end_timestamp) -> bool:
# 1. data type
if description.get(enums.DataFormatKeys.DATA_TYPE.value) != enums.DataType.EXCHANGE.value:
return False
# 2. version (exchange collector always writes CURRENT_VERSION)
if description.get(enums.DataFormatKeys.VERSION.value) != constants.CURRENT_VERSION:
return False
# 3. exchange name
if description.get(enums.DataFormatKeys.EXCHANGE.value) != exchange_name:
return False
# 4. symbols (order-independent)
if self._sorted_str_list(description.get(enums.DataFormatKeys.SYMBOLS.value, [])) \
!= self._sorted_str_list(symbols):
return False
# 5. time frames (order-independent)
existing_tfs = self._sorted_str_list(
tf.value if hasattr(tf, "value") else tf
for tf in description.get(enums.DataFormatKeys.TIME_FRAMES.value, [])
)
requested_tfs = self._sorted_str_list(
tf.value if hasattr(tf, "value") else tf for tf in (time_frames or [])
)
if existing_tfs != requested_tfs:
return False
# 6. timestamps
existing_start = description.get(enums.DataFormatKeys.START_TIMESTAMP.value, 0)
existing_end = description.get(enums.DataFormatKeys.END_TIMESTAMP.value, 0)
return self._timestamps_match(existing_start, existing_end, start_timestamp, end_timestamp)

def social_description_matches(self, description: dict,
services: list,
symbols: list,
start_timestamp,
end_timestamp) -> bool:
# 1. data type
if description.get(enums.DataFormatKeys.DATA_TYPE.value) != enums.DataType.SOCIAL.value:
return False
# 2. version
if description.get(enums.DataFormatKeys.VERSION.value) != constants.CURRENT_VERSION:
return False
# 3. service names
# Collector descriptions can contain the selected feed class plus its
# required underlying services. Accept a superset in existing files.
existing_services = self._sorted_str_list(description.get(enums.DataFormatKeys.SERVICES.value, []))
requested_services = self._sorted_str_list(services)
if not requested_services:
return False
if not set(requested_services).issubset(set(existing_services)):
return False
# 4. symbols (order-independent)
# A social file with no symbols means "all symbols": it can satisfy any
# requested symbol filter. A symbol-scoped file must match exactly.
existing_symbols = self._sorted_str_list(description.get(enums.DataFormatKeys.SYMBOLS.value, []))
requested_symbols = self._sorted_str_list(symbols)
if existing_symbols and existing_symbols != requested_symbols:
return False
# 5. timestamps
existing_start = description.get(enums.DataFormatKeys.START_TIMESTAMP.value, 0)
existing_end = description.get(enums.DataFormatKeys.END_TIMESTAMP.value, 0)
return self._timestamps_match(existing_start, existing_end, start_timestamp, end_timestamp)

def description_matches(self, description: dict, **kwargs) -> bool:
data_type = description.get(enums.DataFormatKeys.DATA_TYPE.value)
if data_type == enums.DataType.EXCHANGE.value:
return self.exchange_description_matches(description, **kwargs)
if data_type == enums.DataType.SOCIAL.value:
return self.social_description_matches(description, **kwargs)
return False

async def find_matching_data_file(self, **kwargs) -> str | None:
for file_name in data.get_all_available_data_files(self.data_path):
description = await data.get_file_description(path.join(self.data_path, file_name))
if description is None:
continue
try:
if self.description_matches(description, **kwargs):
self.logger.debug(f"Found existing matching data file: {file_name}")
return file_name
except Exception as e:
self.logger.debug(f"Could not compare description of {file_name}: {e}")
return None
Empty file.
Loading
Loading