ohlc-toolkit 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ohlc_toolkit-0.1.0/LICENSE +21 -0
- ohlc_toolkit-0.1.0/PKG-INFO +37 -0
- ohlc_toolkit-0.1.0/README.md +10 -0
- ohlc_toolkit-0.1.0/pyproject.toml +71 -0
- ohlc_toolkit-0.1.0/src/ohlc_toolkit/__init__.py +1 -0
- ohlc_toolkit-0.1.0/src/ohlc_toolkit/config/__init__.py +3 -0
- ohlc_toolkit-0.1.0/src/ohlc_toolkit/config/log_config.py +189 -0
- ohlc_toolkit-0.1.0/src/ohlc_toolkit/csv_reader.py +86 -0
- ohlc_toolkit-0.1.0/src/ohlc_toolkit/timeframes.py +113 -0
- ohlc_toolkit-0.1.0/src/ohlc_toolkit/utils.py +38 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) [2025] [Mourits de Beer]
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: ohlc-toolkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A flexible toolkit for working with OHLC data and generating custom time frames from minute data.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: ohlc,price,candlestick,financial,market-data,time-series
|
|
7
|
+
Author: Mourits de Beer
|
|
8
|
+
Author-email: ff137@proton.me
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Provides-Extra: ipyparallel
|
|
17
|
+
Requires-Dist: ipyparallel (>=9.0.0,<10.0.0) ; extra == "ipyparallel"
|
|
18
|
+
Requires-Dist: loguru (>=0.7.3)
|
|
19
|
+
Requires-Dist: numpy (>=2.2.2,<3.0.0)
|
|
20
|
+
Requires-Dist: orjson (>=3.10.15,<4.0.0)
|
|
21
|
+
Requires-Dist: pandas (>=2.2.2,<3.0.0)
|
|
22
|
+
Requires-Dist: pyarrow (>=19.0.0,<20.0.0)
|
|
23
|
+
Requires-Dist: tqdm (>=4.66.4,<5.0.0)
|
|
24
|
+
Project-URL: Repository, https://github.com/ff137/ohlc-toolkit
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# OHLC Toolkit
|
|
28
|
+
|
|
29
|
+
A flexible toolkit for working with OHLC (Open, High, Low, Close) data and generating custom time frames from minute data.
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
- Read OHLC data from CSV files
|
|
34
|
+
- Process high resolution data (e.g. 1-minute interval data) into any greater time frame
|
|
35
|
+
- Calculate future returns from OHLC data
|
|
36
|
+
- Compute technical indicators
|
|
37
|
+
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# OHLC Toolkit
|
|
2
|
+
|
|
3
|
+
A flexible toolkit for working with OHLC (Open, High, Low, Close) data and generating custom time frames from minute data.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Read OHLC data from CSV files
|
|
8
|
+
- Process high resolution data (e.g. 1-minute interval data) into any greater time frame
|
|
9
|
+
- Calculate future returns from OHLC data
|
|
10
|
+
- Compute technical indicators
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "ohlc-toolkit"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A flexible toolkit for working with OHLC data and generating custom time frames from minute data."
|
|
5
|
+
authors = ["Mourits de Beer <ff137@proton.me>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
readme = "README.md"
|
|
8
|
+
keywords = [
|
|
9
|
+
"ohlc",
|
|
10
|
+
"price",
|
|
11
|
+
"candlestick",
|
|
12
|
+
"financial",
|
|
13
|
+
"market-data",
|
|
14
|
+
"time-series",
|
|
15
|
+
]
|
|
16
|
+
repository = "https://github.com/ff137/ohlc-toolkit"
|
|
17
|
+
|
|
18
|
+
[tool.poetry.dependencies]
|
|
19
|
+
python = "^3.10"
|
|
20
|
+
loguru = ">=0.7.3"
|
|
21
|
+
numpy = "^2.2.2"
|
|
22
|
+
orjson = "^3.10.15"
|
|
23
|
+
pandas = "^2.2.2"
|
|
24
|
+
pyarrow = "^19.0.0"
|
|
25
|
+
tqdm = "^4.66.4"
|
|
26
|
+
# pyspark = { version = ">=3.2.0", optional = true }
|
|
27
|
+
ipyparallel = { version = "^9.0.0", optional = true }
|
|
28
|
+
|
|
29
|
+
[tool.poetry.extras]
|
|
30
|
+
# spark = ["pyspark"]
|
|
31
|
+
ipyparallel = ["ipyparallel"]
|
|
32
|
+
|
|
33
|
+
[tool.poetry.group.dev.dependencies]
|
|
34
|
+
pytest = "^8.3.4"
|
|
35
|
+
pytest-cov = "^6.0.0"
|
|
36
|
+
pytest-ruff = "^0.4.1"
|
|
37
|
+
ruff = "^0.9.4"
|
|
38
|
+
|
|
39
|
+
[tool.ruff]
|
|
40
|
+
lint.select = ["B006", "C", "D", "E", "F"]
|
|
41
|
+
lint.ignore = [
|
|
42
|
+
"D203",
|
|
43
|
+
"D204",
|
|
44
|
+
"D213",
|
|
45
|
+
"D215",
|
|
46
|
+
"D400",
|
|
47
|
+
"D401",
|
|
48
|
+
"D404",
|
|
49
|
+
"D406",
|
|
50
|
+
"D407",
|
|
51
|
+
"D408",
|
|
52
|
+
"D409",
|
|
53
|
+
"D413",
|
|
54
|
+
]
|
|
55
|
+
include = ["src/*.py"]
|
|
56
|
+
line-length = 88
|
|
57
|
+
|
|
58
|
+
[tool.pytest.ini_options]
|
|
59
|
+
testpaths = "tests"
|
|
60
|
+
addopts = "--cov=src --cov-report term --ruff --ruff-format"
|
|
61
|
+
|
|
62
|
+
[tool.coverage.run]
|
|
63
|
+
omit = ["tests/*", "examples/*"]
|
|
64
|
+
|
|
65
|
+
[tool.coverage.report]
|
|
66
|
+
skip_covered = false
|
|
67
|
+
show_missing = true
|
|
68
|
+
|
|
69
|
+
[build-system]
|
|
70
|
+
requires = ["poetry-core>=2.0.0"]
|
|
71
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""OHLC Toolkit."""
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Configuration for the ohlc_toolkit package."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
import orjson
|
|
8
|
+
from loguru._logger import Core as _Core
|
|
9
|
+
from loguru._logger import Logger as _Logger
|
|
10
|
+
|
|
11
|
+
STDOUT_LOG_LEVEL = os.getenv("LOG_LEVEL", "DEBUG").upper()
|
|
12
|
+
FILE_LOG_LEVEL = os.getenv("FILE_LOG_LEVEL", "DEBUG").upper()
|
|
13
|
+
ENABLE_FILE_LOGGING = os.getenv("ENABLE_FILE_LOGGING", "").upper() == "TRUE"
|
|
14
|
+
DISABLE_COLORIZE_LOGS = os.getenv("DISABLE_COLORIZE_LOGS", "").upper() == "TRUE"
|
|
15
|
+
ENABLE_SERIALIZE_LOGS = os.getenv("ENABLE_SERIALIZE_LOGS", "").upper() == "TRUE"
|
|
16
|
+
LOGURU_DIAGNOSE = os.getenv("LOGURU_DIAGNOSE", "").upper() == "TRUE"
|
|
17
|
+
|
|
18
|
+
colorize = not DISABLE_COLORIZE_LOGS
|
|
19
|
+
serialize = ENABLE_SERIALIZE_LOGS
|
|
20
|
+
|
|
21
|
+
# Create a mapping of module name to color
|
|
22
|
+
color_map = {
|
|
23
|
+
"ohlc_toolkit": "blue",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def formatter_builder(color: str):
|
|
28
|
+
"""Build a formatter for the logger."""
|
|
29
|
+
return (
|
|
30
|
+
"<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | "
|
|
31
|
+
"<level>{level: <8}</level> | "
|
|
32
|
+
f"<{color}>{{name}}</{color}>"
|
|
33
|
+
f":<{color}>"
|
|
34
|
+
f"{{function}}</{color}>"
|
|
35
|
+
f":<{color}>"
|
|
36
|
+
f"{{line}}</{color}>"
|
|
37
|
+
" | "
|
|
38
|
+
"<level>{message}</level> | "
|
|
39
|
+
"{extra[body]}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Define custom formatter for serialized logs
|
|
44
|
+
def _serialize_record(record):
|
|
45
|
+
record_time: datetime = record["time"]
|
|
46
|
+
|
|
47
|
+
# format the time field to ISO8601 format
|
|
48
|
+
iso_date = record_time.isoformat()
|
|
49
|
+
|
|
50
|
+
# Handle exceptions as default
|
|
51
|
+
exception = record["exception"]
|
|
52
|
+
|
|
53
|
+
if exception is not None:
|
|
54
|
+
exception = {
|
|
55
|
+
"type": None if exception.type is None else exception.type.__name__,
|
|
56
|
+
"value": exception.value,
|
|
57
|
+
"traceback": bool(exception.traceback),
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# Define subset of serialized record - combining message + extra into the text field
|
|
61
|
+
message = record["message"]
|
|
62
|
+
extra = record["extra"].get("body")
|
|
63
|
+
message_with_body = f"{message} | {extra}"
|
|
64
|
+
|
|
65
|
+
# we keep fields commented out to compare with loguru's default serialisation
|
|
66
|
+
subset = {
|
|
67
|
+
"message": message_with_body,
|
|
68
|
+
"levelname": record["level"].name, # log level
|
|
69
|
+
"date": iso_date,
|
|
70
|
+
"name": record["name"],
|
|
71
|
+
"record": {
|
|
72
|
+
# "elapsed": {
|
|
73
|
+
# "repr": record["elapsed"],
|
|
74
|
+
# "seconds": record["elapsed"].total_seconds(),
|
|
75
|
+
# },
|
|
76
|
+
"exception": exception,
|
|
77
|
+
# "extra": record["extra"],
|
|
78
|
+
# "file": {"name": record["file"].name, "path": record["file"].path},
|
|
79
|
+
"file": record["file"].path,
|
|
80
|
+
"function": record["function"],
|
|
81
|
+
# "level": {
|
|
82
|
+
# "icon": record["level"].icon,
|
|
83
|
+
# "name": record["level"].name,
|
|
84
|
+
# "no": record["level"].no,
|
|
85
|
+
# },
|
|
86
|
+
"line": record["line"],
|
|
87
|
+
# "message": record["message"],
|
|
88
|
+
# "module": record["module"],
|
|
89
|
+
"process": {"id": record["process"].id, "name": record["process"].name},
|
|
90
|
+
"thread": {"id": record["thread"].id, "name": record["thread"].name},
|
|
91
|
+
"time": {
|
|
92
|
+
"repr": int(1000 * record_time.timestamp()), # to milliseconds
|
|
93
|
+
"uptime_h:m:s": record["elapsed"],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
record["extra"]["serialized"] = orjson.dumps(subset, default=str).decode("utf-8")
|
|
98
|
+
return "{extra[serialized]}\n"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# This will hold our logger instances
|
|
102
|
+
loggers = {}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_log_file_path(main_module_name) -> str:
|
|
106
|
+
"""Get the log file path for the given module name."""
|
|
107
|
+
# The absolute path of this file's directory
|
|
108
|
+
config_dir = os.path.dirname(os.path.abspath(__file__))
|
|
109
|
+
|
|
110
|
+
# Move up one level to get to the project root directory
|
|
111
|
+
base_dir = os.path.dirname(config_dir)
|
|
112
|
+
|
|
113
|
+
# Define the logging dir with
|
|
114
|
+
log_dir = os.path.join(base_dir, f"logs/{main_module_name}")
|
|
115
|
+
return os.path.join(log_dir, "{time:YYYY-MM-DD}.log")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def get_logger(name: str):
|
|
119
|
+
"""Get a logger instance for the given module name."""
|
|
120
|
+
# Get the main module name
|
|
121
|
+
main_module_name = name.split(".")[0]
|
|
122
|
+
|
|
123
|
+
# Check if a logger for this name already exists
|
|
124
|
+
if main_module_name in loggers:
|
|
125
|
+
return loggers[main_module_name].bind(name=name)
|
|
126
|
+
|
|
127
|
+
# Create a new logger instance
|
|
128
|
+
logger_ = _Logger(
|
|
129
|
+
core=_Core(),
|
|
130
|
+
exception=None,
|
|
131
|
+
depth=0,
|
|
132
|
+
record=False,
|
|
133
|
+
lazy=False,
|
|
134
|
+
colors=False,
|
|
135
|
+
raw=False,
|
|
136
|
+
capture=True,
|
|
137
|
+
patchers=[],
|
|
138
|
+
extra={},
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
logger_.configure(extra={"body": ""}) # Default values for extra args
|
|
142
|
+
|
|
143
|
+
if not serialize:
|
|
144
|
+
# Get the color for this module and build formatter
|
|
145
|
+
color = color_map.get(main_module_name, "blue") # Default to blue if no mapping
|
|
146
|
+
formatter = formatter_builder(color)
|
|
147
|
+
|
|
148
|
+
# Log to stdout
|
|
149
|
+
logger_.add(
|
|
150
|
+
sys.stdout,
|
|
151
|
+
level=STDOUT_LOG_LEVEL,
|
|
152
|
+
diagnose=True, # for local dev
|
|
153
|
+
format=formatter,
|
|
154
|
+
colorize=colorize,
|
|
155
|
+
)
|
|
156
|
+
else: # serialization is enabled:
|
|
157
|
+
logger_.add(
|
|
158
|
+
sys.stdout,
|
|
159
|
+
level=STDOUT_LOG_LEVEL,
|
|
160
|
+
diagnose=LOGURU_DIAGNOSE, # default = disabled for serialized logs
|
|
161
|
+
format=_serialize_record, # Use our custom serialization formatter
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Log to a file
|
|
165
|
+
if ENABLE_FILE_LOGGING:
|
|
166
|
+
try:
|
|
167
|
+
logger_.add(
|
|
168
|
+
get_log_file_path(main_module_name),
|
|
169
|
+
rotation="00:00", # new file is created at midnight
|
|
170
|
+
retention="7 days", # keep logs for up to 7 days
|
|
171
|
+
enqueue=True, # asynchronous
|
|
172
|
+
level=FILE_LOG_LEVEL,
|
|
173
|
+
diagnose=True,
|
|
174
|
+
format=formatter_builder("blue"),
|
|
175
|
+
serialize=serialize,
|
|
176
|
+
)
|
|
177
|
+
except PermissionError:
|
|
178
|
+
logger_.warning(
|
|
179
|
+
"Permission error caught when trying to create log file. "
|
|
180
|
+
"Continuing without file logging for `{}` in `{}`",
|
|
181
|
+
name,
|
|
182
|
+
main_module_name,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
# Store the logger in the dictionary
|
|
186
|
+
loggers[main_module_name] = logger_
|
|
187
|
+
|
|
188
|
+
# Return a logger bound with the full name including the submodule
|
|
189
|
+
return logger_.bind(name=name)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Module for loading OHLC data from a CSV file."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from ohlc_toolkit.config import EXPECTED_COLUMNS
|
|
8
|
+
from ohlc_toolkit.config.log_config import get_logger
|
|
9
|
+
from ohlc_toolkit.timeframes import (
|
|
10
|
+
parse_timeframe,
|
|
11
|
+
validate_timeframe,
|
|
12
|
+
validate_timeframe_format,
|
|
13
|
+
)
|
|
14
|
+
from ohlc_toolkit.utils import check_data_integrity, infer_time_step
|
|
15
|
+
|
|
16
|
+
logger = get_logger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def read_ohlc_csv(
|
|
20
|
+
filepath: str,
|
|
21
|
+
timeframe: Optional[str] = None,
|
|
22
|
+
expected_columns: Optional[list[str]] = None,
|
|
23
|
+
header_row: Optional[int] = None,
|
|
24
|
+
dtype: Optional[dict[str, str]] = None,
|
|
25
|
+
) -> pd.DataFrame:
|
|
26
|
+
"""Read OHLC data from a CSV file.
|
|
27
|
+
|
|
28
|
+
Arguments:
|
|
29
|
+
filepath (str): Path to the CSV file.
|
|
30
|
+
timeframe (Optional[str]): User-defined timeframe (e.g., '1m', '5m', '1h').
|
|
31
|
+
expected_columns (Optional[list[str]]): The expected columns in the CSV file.
|
|
32
|
+
header_row (Optional[int]): The row number to use as the header.
|
|
33
|
+
dtype (Optional[dict[str, str]]): The data type for the columns.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
pd.DataFrame: Processed OHLC dataset.
|
|
37
|
+
"""
|
|
38
|
+
bound_logger = logger.bind(body=filepath)
|
|
39
|
+
bound_logger.info("Reading OHLC data")
|
|
40
|
+
|
|
41
|
+
if expected_columns is None:
|
|
42
|
+
expected_columns = EXPECTED_COLUMNS
|
|
43
|
+
if dtype is None:
|
|
44
|
+
dtype = {"timestamp": "int32"}
|
|
45
|
+
|
|
46
|
+
read_csv_params = {
|
|
47
|
+
"filepath_or_buffer": filepath,
|
|
48
|
+
"names": expected_columns,
|
|
49
|
+
"dtype": dtype,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# If header_row is provided, use it directly
|
|
53
|
+
if header_row is not None:
|
|
54
|
+
df = pd.read_csv(**read_csv_params, header=header_row)
|
|
55
|
+
else:
|
|
56
|
+
# User doesn't specify header - let's try reading without header first
|
|
57
|
+
try:
|
|
58
|
+
df = pd.read_csv(**read_csv_params, header=None)
|
|
59
|
+
except FileNotFoundError:
|
|
60
|
+
raise FileNotFoundError(f"File not found: {filepath}")
|
|
61
|
+
except ValueError:
|
|
62
|
+
# If that fails, try with header
|
|
63
|
+
df = pd.read_csv(**read_csv_params, header=0)
|
|
64
|
+
|
|
65
|
+
bound_logger.debug(
|
|
66
|
+
f"Read {df.shape[0]} rows and {df.shape[1]} columns: {df.columns.tolist()}"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Infer time step from data
|
|
70
|
+
time_step = infer_time_step(df)
|
|
71
|
+
|
|
72
|
+
# Convert user-defined timeframe to seconds
|
|
73
|
+
timeframe_seconds = None
|
|
74
|
+
if timeframe:
|
|
75
|
+
if not validate_timeframe_format(timeframe):
|
|
76
|
+
raise ValueError(f"Invalid timeframe format: {timeframe}")
|
|
77
|
+
|
|
78
|
+
timeframe_seconds = parse_timeframe(timeframe)
|
|
79
|
+
|
|
80
|
+
validate_timeframe(time_step, timeframe_seconds, bound_logger)
|
|
81
|
+
|
|
82
|
+
# Perform integrity checks
|
|
83
|
+
check_data_integrity(df, time_step)
|
|
84
|
+
|
|
85
|
+
bound_logger.info("OHLC data successfully loaded.")
|
|
86
|
+
return df
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""This module contains functions for parsing and formatting timeframes."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from logging import Logger
|
|
5
|
+
|
|
6
|
+
# Predefined common timeframes for faster lookup
|
|
7
|
+
COMMON_TIMEFRAMES = {
|
|
8
|
+
"1m": 60,
|
|
9
|
+
"3m": 180,
|
|
10
|
+
"5m": 300,
|
|
11
|
+
"15m": 900,
|
|
12
|
+
"30m": 1800,
|
|
13
|
+
"1h": 3600,
|
|
14
|
+
"2h": 7200,
|
|
15
|
+
"4h": 14400,
|
|
16
|
+
"6h": 21600,
|
|
17
|
+
"8h": 28800,
|
|
18
|
+
"12h": 43200,
|
|
19
|
+
"1d": 86400,
|
|
20
|
+
"2d": 172800,
|
|
21
|
+
"3d": 259200,
|
|
22
|
+
"4d": 345600,
|
|
23
|
+
"1w": 604800,
|
|
24
|
+
"2w": 1209600,
|
|
25
|
+
"3w": 1814400,
|
|
26
|
+
"4w": 2419200,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
# Regex pattern to parse timeframe strings
|
|
30
|
+
TIMEFRAME_PATTERN = re.compile(r"(\d+)([wdhms])", re.IGNORECASE)
|
|
31
|
+
TIMEFRAME_FORMAT_PATTERN = re.compile(r"^(\d+[wdhms])+$", re.IGNORECASE)
|
|
32
|
+
|
|
33
|
+
# Unit conversion
|
|
34
|
+
TIME_UNITS = {
|
|
35
|
+
"w": 604800, # Weeks to seconds
|
|
36
|
+
"d": 86400, # Days to seconds
|
|
37
|
+
"h": 3600, # Hours to seconds
|
|
38
|
+
"m": 60, # Minutes to seconds
|
|
39
|
+
"s": 1, # Seconds to seconds
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_timeframe(timeframe: str) -> int:
|
|
44
|
+
"""Convert a timeframe string (e.g., '1h', '4h30m', '1w3d7h14m') into total seconds.
|
|
45
|
+
|
|
46
|
+
Arguments:
|
|
47
|
+
timeframe (str): Human-readable timeframe.
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
int: Total number of seconds.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
ValueError: If the format is invalid.
|
|
54
|
+
"""
|
|
55
|
+
if not validate_timeframe_format(timeframe):
|
|
56
|
+
raise ValueError(f"Invalid timeframe format: {timeframe}")
|
|
57
|
+
|
|
58
|
+
matches = TIMEFRAME_PATTERN.findall(timeframe)
|
|
59
|
+
total_seconds = sum(
|
|
60
|
+
int(amount) * TIME_UNITS[unit.lower()] for amount, unit in matches
|
|
61
|
+
)
|
|
62
|
+
return total_seconds
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def format_timeframe(seconds: int) -> str:
|
|
66
|
+
"""Convert a total number of seconds into a human-readable timeframe string.
|
|
67
|
+
|
|
68
|
+
Arguments:
|
|
69
|
+
seconds (int): Total number of seconds.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
str: Human-readable timeframe string (e.g., '1h', '4h30m').
|
|
73
|
+
"""
|
|
74
|
+
if seconds in COMMON_TIMEFRAMES.values():
|
|
75
|
+
# Return predefined common timeframes if found
|
|
76
|
+
return {v: k for k, v in COMMON_TIMEFRAMES.items()}[seconds]
|
|
77
|
+
|
|
78
|
+
units = [("w", 604800), ("d", 86400), ("h", 3600), ("m", 60), ("s", 1)]
|
|
79
|
+
parts = []
|
|
80
|
+
|
|
81
|
+
for unit, unit_seconds in units:
|
|
82
|
+
value, seconds = divmod(seconds, unit_seconds)
|
|
83
|
+
if value > 0:
|
|
84
|
+
parts.append(f"{value}{unit}")
|
|
85
|
+
|
|
86
|
+
return "".join(parts)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def validate_timeframe_format(timeframe: str) -> bool:
|
|
90
|
+
"""Validate whether a given timeframe string follows the expected format.
|
|
91
|
+
|
|
92
|
+
Arguments:
|
|
93
|
+
timeframe (str): Timeframe string to validate.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
bool: True if valid, False otherwise.
|
|
97
|
+
"""
|
|
98
|
+
return bool(TIMEFRAME_FORMAT_PATTERN.fullmatch(timeframe))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_timeframe(time_step: int, user_timeframe: int, logger: Logger):
|
|
102
|
+
"""Ensure that the timeframe is valid given the time step."""
|
|
103
|
+
if user_timeframe < time_step:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"Provided timeframe ({user_timeframe}s) cannot be smaller "
|
|
106
|
+
f"than inferred time step ({time_step}s)."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if user_timeframe % time_step != 0:
|
|
110
|
+
logger.warning(
|
|
111
|
+
f"Provided timeframe ({user_timeframe}s) is not a multiple "
|
|
112
|
+
f"of the inferred time step ({time_step}s). Data may be incomplete."
|
|
113
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""This module contains utility functions for the OHLC toolkit."""
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from loguru import logger
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def infer_time_step(df: pd.DataFrame) -> int:
|
|
11
|
+
"""Infer the time step by analyzing the timestamp column."""
|
|
12
|
+
time_diffs = np.diff(df["timestamp"])
|
|
13
|
+
|
|
14
|
+
if len(time_diffs) == 0:
|
|
15
|
+
raise ValueError("Cannot infer time step from a single-row dataset.")
|
|
16
|
+
|
|
17
|
+
time_step = int(pd.Series(time_diffs).mode()[0]) # Most frequent difference
|
|
18
|
+
logger.info(f"Inferred time step: {time_step} seconds")
|
|
19
|
+
return time_step
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def check_data_integrity(df: pd.DataFrame, time_step: Optional[int] = None):
|
|
23
|
+
"""Perform basic data integrity checks on the OHLC dataset."""
|
|
24
|
+
if df.isnull().values.any():
|
|
25
|
+
logger.warning("Data contains null values.")
|
|
26
|
+
|
|
27
|
+
if df["timestamp"].duplicated().any():
|
|
28
|
+
logger.warning("Duplicate timestamps found in the dataset.")
|
|
29
|
+
|
|
30
|
+
if time_step:
|
|
31
|
+
expected_timestamps = set(
|
|
32
|
+
range(df["timestamp"].min(), df["timestamp"].max() + time_step, time_step)
|
|
33
|
+
)
|
|
34
|
+
actual_timestamps = set(df["timestamp"])
|
|
35
|
+
missing_timestamps = expected_timestamps - actual_timestamps
|
|
36
|
+
|
|
37
|
+
if missing_timestamps:
|
|
38
|
+
logger.warning(f"Missing {len(missing_timestamps)} timestamps in dataset.")
|