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.
@@ -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,3 @@
1
+ """Configuration for the ohlc_toolkit package."""
2
+
3
+ EXPECTED_COLUMNS = ["timestamp", "open", "high", "low", "close", "volume"]
@@ -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.")