opencode-pyneruntime 6.6.4__py3-none-any.whl
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.
- opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
- opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
- opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
- opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
- opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
- pynecore/__init__.py +6 -0
- pynecore/cli/__init__.py +2 -0
- pynecore/cli/app.py +238 -0
- pynecore/cli/commands/__init__.py +343 -0
- pynecore/cli/commands/benchmark.py +186 -0
- pynecore/cli/commands/compile.py +198 -0
- pynecore/cli/commands/data.py +857 -0
- pynecore/cli/commands/debug.py +63 -0
- pynecore/cli/commands/optimize.py +956 -0
- pynecore/cli/commands/plugin.py +242 -0
- pynecore/cli/commands/run.py +2006 -0
- pynecore/cli/pluggable.py +132 -0
- pynecore/cli/utils/__init__.py +0 -0
- pynecore/cli/utils/api_error_handler.py +168 -0
- pynecore/cli/utils/broker_picker.py +330 -0
- pynecore/cli/utils/error_hook.py +28 -0
- pynecore/cli/utils/keyreader.py +178 -0
- pynecore/cli/utils/provider_picker.py +19 -0
- pynecore/cli/utils/symbol_browser.py +1149 -0
- pynecore/core/__init__.py +0 -0
- pynecore/core/aggregator.py +257 -0
- pynecore/core/bar_magnifier.py +168 -0
- pynecore/core/broker/__init__.py +64 -0
- pynecore/core/broker/defaults.py +113 -0
- pynecore/core/broker/disappearance.py +927 -0
- pynecore/core/broker/emulator.py +345 -0
- pynecore/core/broker/exceptions.py +346 -0
- pynecore/core/broker/idempotency.py +401 -0
- pynecore/core/broker/intent_builder.py +334 -0
- pynecore/core/broker/journal.py +1785 -0
- pynecore/core/broker/models.py +1600 -0
- pynecore/core/broker/native_failsafe_manager.py +1436 -0
- pynecore/core/broker/one_way_emulator.py +1128 -0
- pynecore/core/broker/position.py +787 -0
- pynecore/core/broker/run_identity.py +126 -0
- pynecore/core/broker/software_entry_stop_engine.py +351 -0
- pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
- pynecore/core/broker/spot_inventory.py +1327 -0
- pynecore/core/broker/storage.py +2655 -0
- pynecore/core/broker/store_helpers.py +2161 -0
- pynecore/core/broker/sync_engine.py +16070 -0
- pynecore/core/broker/validation.py +382 -0
- pynecore/core/class_property.py +7 -0
- pynecore/core/config.py +392 -0
- pynecore/core/csv_file.py +547 -0
- pynecore/core/currency.py +262 -0
- pynecore/core/data_converter.py +1002 -0
- pynecore/core/datetime.py +296 -0
- pynecore/core/download_info.py +71 -0
- pynecore/core/download_runner.py +274 -0
- pynecore/core/htf_aggregator.py +181 -0
- pynecore/core/import_hook.py +358 -0
- pynecore/core/instance_state.py +494 -0
- pynecore/core/live_ltf_collector.py +442 -0
- pynecore/core/live_ltf_window.py +189 -0
- pynecore/core/live_runner.py +1347 -0
- pynecore/core/module_property.py +26 -0
- pynecore/core/ohlcv_file.py +1888 -0
- pynecore/core/overload.py +371 -0
- pynecore/core/pine_cast.py +113 -0
- pynecore/core/pine_export.py +95 -0
- pynecore/core/pine_method.py +244 -0
- pynecore/core/pine_range.py +86 -0
- pynecore/core/pine_udt.py +69 -0
- pynecore/core/plugin/__init__.py +394 -0
- pynecore/core/plugin/broker.py +781 -0
- pynecore/core/plugin/cli.py +96 -0
- pynecore/core/plugin/live_provider.py +208 -0
- pynecore/core/plugin/provider.py +331 -0
- pynecore/core/provider_string.py +148 -0
- pynecore/core/random.py +40 -0
- pynecore/core/resampler.py +686 -0
- pynecore/core/safe_convert.py +64 -0
- pynecore/core/script.py +1011 -0
- pynecore/core/script_runner.py +3202 -0
- pynecore/core/security.py +1749 -0
- pynecore/core/security_process.py +1253 -0
- pynecore/core/security_shm.py +456 -0
- pynecore/core/series.py +417 -0
- pynecore/core/strategy_stats.py +669 -0
- pynecore/core/symbol_map.py +134 -0
- pynecore/core/syminfo.py +505 -0
- pynecore/core/viz.py +591 -0
- pynecore/lib/__init__.py +1771 -0
- pynecore/lib/_fixnan.py +32 -0
- pynecore/lib/_math_stateful.py +202 -0
- pynecore/lib/_timeframe_change.py +101 -0
- pynecore/lib/adjustment.py +6 -0
- pynecore/lib/alert.py +39 -0
- pynecore/lib/alert.pyi +14 -0
- pynecore/lib/array.py +1051 -0
- pynecore/lib/barmerge.py +60 -0
- pynecore/lib/barstate.py +30 -0
- pynecore/lib/box.py +415 -0
- pynecore/lib/chart.py +128 -0
- pynecore/lib/color.py +152 -0
- pynecore/lib/color.pyi +50 -0
- pynecore/lib/currency.py +62 -0
- pynecore/lib/dayofweek.py +36 -0
- pynecore/lib/dayofweek.pyi +18 -0
- pynecore/lib/display.py +8 -0
- pynecore/lib/dividends.py +9 -0
- pynecore/lib/earnings.py +11 -0
- pynecore/lib/extend.py +6 -0
- pynecore/lib/font.py +5 -0
- pynecore/lib/footprint.py +79 -0
- pynecore/lib/format.py +11 -0
- pynecore/lib/hline.py +67 -0
- pynecore/lib/hline.pyi +24 -0
- pynecore/lib/label.py +409 -0
- pynecore/lib/line.py +433 -0
- pynecore/lib/linefill.py +93 -0
- pynecore/lib/location.py +11 -0
- pynecore/lib/log.py +362 -0
- pynecore/lib/map.py +150 -0
- pynecore/lib/math.py +385 -0
- pynecore/lib/matrix.py +708 -0
- pynecore/lib/order.py +8 -0
- pynecore/lib/pivotpointtype.py +8 -0
- pynecore/lib/plot.py +95 -0
- pynecore/lib/plot.pyi +33 -0
- pynecore/lib/polyline.py +91 -0
- pynecore/lib/position.py +15 -0
- pynecore/lib/request.py +281 -0
- pynecore/lib/runtime.py +5 -0
- pynecore/lib/scale.py +9 -0
- pynecore/lib/session.py +267 -0
- pynecore/lib/session.pyi +12 -0
- pynecore/lib/shape.py +18 -0
- pynecore/lib/size.py +12 -0
- pynecore/lib/splits.py +4 -0
- pynecore/lib/strategy/__init__.py +4778 -0
- pynecore/lib/strategy/closedtrades.py +347 -0
- pynecore/lib/strategy/closedtrades.pyi +53 -0
- pynecore/lib/strategy/commission.py +9 -0
- pynecore/lib/strategy/direction.py +9 -0
- pynecore/lib/strategy/oca.py +13 -0
- pynecore/lib/strategy/opentrades.py +281 -0
- pynecore/lib/strategy/opentrades.pyi +49 -0
- pynecore/lib/strategy/risk.py +109 -0
- pynecore/lib/string.py +649 -0
- pynecore/lib/syminfo.py +84 -0
- pynecore/lib/ta.py +2230 -0
- pynecore/lib/table.py +290 -0
- pynecore/lib/text.py +17 -0
- pynecore/lib/ticker.py +207 -0
- pynecore/lib/timeframe.py +293 -0
- pynecore/lib/volume_row.py +67 -0
- pynecore/lib/xloc.py +4 -0
- pynecore/lib/yloc.py +5 -0
- pynecore/providers/__init__.py +0 -0
- pynecore/providers/ccxt.py +664 -0
- pynecore/providers/replay.py +187 -0
- pynecore/pynesys/__init__.py +0 -0
- pynecore/pynesys/api.py +498 -0
- pynecore/pynesys/compiler.py +112 -0
- pynecore/standalone.py +99 -0
- pynecore/testing/__init__.py +1 -0
- pynecore/testing/broker_lab/__init__.py +41 -0
- pynecore/testing/broker_lab/__main__.py +5 -0
- pynecore/testing/broker_lab/cli.py +87 -0
- pynecore/testing/broker_lab/generate.py +47 -0
- pynecore/testing/broker_lab/model.py +84 -0
- pynecore/testing/broker_lab/reference.py +645 -0
- pynecore/testing/broker_lab/runner.py +372 -0
- pynecore/testing/broker_lab/scheduler.py +50 -0
- pynecore/testing/broker_lab/subprocess.py +73 -0
- pynecore/transformers/__init__.py +0 -0
- pynecore/transformers/builtin_shadow.py +136 -0
- pynecore/transformers/closure_arguments_transformer.py +428 -0
- pynecore/transformers/display_rewrite.py +140 -0
- pynecore/transformers/dynamic_default.py +147 -0
- pynecore/transformers/function_isolation.py +757 -0
- pynecore/transformers/import_lifter.py +61 -0
- pynecore/transformers/import_normalizer.py +328 -0
- pynecore/transformers/inline_series_hoist.py +178 -0
- pynecore/transformers/input_transformer.py +175 -0
- pynecore/transformers/lib_series.py +201 -0
- pynecore/transformers/locations.py +70 -0
- pynecore/transformers/module_properties.json +3387 -0
- pynecore/transformers/module_property.py +221 -0
- pynecore/transformers/ne_guard.py +70 -0
- pynecore/transformers/persistent.py +320 -0
- pynecore/transformers/persistent_series.py +76 -0
- pynecore/transformers/safe_convert_transformer.py +97 -0
- pynecore/transformers/safe_division_transformer.py +95 -0
- pynecore/transformers/script_requirements.py +308 -0
- pynecore/transformers/security.py +752 -0
- pynecore/transformers/security_instantiation.py +274 -0
- pynecore/transformers/series.py +275 -0
- pynecore/transformers/slot_layout.py +381 -0
- pynecore/transformers/type_checking_stripper.py +25 -0
- pynecore/transformers/unused_series_detector.py +267 -0
- pynecore/types/__init__.py +21 -0
- pynecore/types/alert.py +5 -0
- pynecore/types/barmerge.py +5 -0
- pynecore/types/base.py +39 -0
- pynecore/types/box.py +37 -0
- pynecore/types/chart.py +17 -0
- pynecore/types/color.py +107 -0
- pynecore/types/currency.py +5 -0
- pynecore/types/datetime.py +6 -0
- pynecore/types/display.py +5 -0
- pynecore/types/dividends.py +5 -0
- pynecore/types/earnings.py +5 -0
- pynecore/types/extend.py +5 -0
- pynecore/types/font.py +5 -0
- pynecore/types/footprint.py +41 -0
- pynecore/types/format.py +5 -0
- pynecore/types/hline.py +24 -0
- pynecore/types/ib_persistent.py +8 -0
- pynecore/types/ib_persistent.pyi +10 -0
- pynecore/types/label.py +35 -0
- pynecore/types/line.py +32 -0
- pynecore/types/linefill.py +13 -0
- pynecore/types/location.py +5 -0
- pynecore/types/matrix.py +999 -0
- pynecore/types/na.py +237 -0
- pynecore/types/na.pyi +83 -0
- pynecore/types/ohlcv.py +12 -0
- pynecore/types/order.py +5 -0
- pynecore/types/persistent.py +8 -0
- pynecore/types/persistent.pyi +13 -0
- pynecore/types/pine_types.py +11 -0
- pynecore/types/pine_types.pyi +15 -0
- pynecore/types/pivotpointtype.py +5 -0
- pynecore/types/plot.py +12 -0
- pynecore/types/plot_meta.py +60 -0
- pynecore/types/polyline.py +40 -0
- pynecore/types/position.py +5 -0
- pynecore/types/scale.py +5 -0
- pynecore/types/script_type.py +15 -0
- pynecore/types/series.py +23 -0
- pynecore/types/series.pyi +19 -0
- pynecore/types/session.py +35 -0
- pynecore/types/shape.py +5 -0
- pynecore/types/size.py +5 -0
- pynecore/types/source.py +33 -0
- pynecore/types/splits.py +5 -0
- pynecore/types/strategy.py +45 -0
- pynecore/types/table.py +87 -0
- pynecore/types/text.py +13 -0
- pynecore/types/type_checker.py +7 -0
- pynecore/types/type_checker.pyi +48 -0
- pynecore/types/volume_row.py +36 -0
- pynecore/types/weekdays.py +11 -0
- pynecore/types/xloc.py +5 -0
- pynecore/types/yloc.py +5 -0
- pynecore/utils/__init__.py +0 -0
- pynecore/utils/file_utils.py +50 -0
- pynecore/utils/rich/__init__.py +0 -0
- pynecore/utils/rich/date_column.py +25 -0
- pynecore/utils/sequence_view.py +92 -0
- pynecore/utils/stdlib_checker.py +17 -0
|
@@ -0,0 +1,1888 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fast and efficient OHLCV data reader/writer
|
|
3
|
+
|
|
4
|
+
The file is a binary file with the following 24 bytes structure:
|
|
5
|
+
- timestamp: uint32 (4 bytes) - good until 2106 (I will fix this then, I promise ;))
|
|
6
|
+
- open: float32 (4 bytes)
|
|
7
|
+
- high: float32 (4 bytes)
|
|
8
|
+
- low: float32 (4 bytes)
|
|
9
|
+
- close: float32 (4 bytes)
|
|
10
|
+
- volume: float32 (4 bytes)
|
|
11
|
+
|
|
12
|
+
The .ohlcv format cannot have gaps in it. All gaps are filled with the previous close price and -1 volume.
|
|
13
|
+
"""
|
|
14
|
+
from typing import Iterator
|
|
15
|
+
|
|
16
|
+
import csv
|
|
17
|
+
import json
|
|
18
|
+
import math
|
|
19
|
+
import mmap
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import struct
|
|
23
|
+
from collections import Counter
|
|
24
|
+
from datetime import datetime, time, timedelta, timezone as dt_timezone, UTC
|
|
25
|
+
from io import BufferedWriter, BufferedRandom
|
|
26
|
+
from math import gcd as math_gcd
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from zoneinfo import ZoneInfo
|
|
29
|
+
|
|
30
|
+
from pynecore.types.ohlcv import OHLCV
|
|
31
|
+
from ..core.syminfo import SymInfoInterval
|
|
32
|
+
|
|
33
|
+
RECORD_SIZE = 24 # 6 * 4
|
|
34
|
+
STRUCT_FORMAT = 'Ifffff' # I: uint32, f: float32
|
|
35
|
+
|
|
36
|
+
_QTY_STEP_MIN_SAMPLES = 100 # positive-volume bars needed before analyzed_qty_step answers
|
|
37
|
+
_QTY_STEP_MAX_DECIMALS = 8 # more decimals than any real exchange lot step -> float dust
|
|
38
|
+
|
|
39
|
+
__all__ = ['OHLCVWriter', 'OHLCVReader', 'RECORD_SIZE', 'STRUCT_FORMAT']
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _format_float(value: float) -> str:
|
|
43
|
+
"""Format float with max 8 decimal places, removing trailing zeros"""
|
|
44
|
+
return f"{value:.8g}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_timezone_param(tz: str | None) -> dt_timezone | ZoneInfo | None:
|
|
48
|
+
"""
|
|
49
|
+
Parse timezone parameter into a timezone object.
|
|
50
|
+
|
|
51
|
+
:param tz: Timezone string (e.g. 'UTC', 'Europe/London', '+0100', '-0500')
|
|
52
|
+
:return: Timezone object or None if tz is None
|
|
53
|
+
:raises ValueError: If timezone format is invalid
|
|
54
|
+
"""
|
|
55
|
+
if not tz:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
if tz.startswith(('+', '-')):
|
|
59
|
+
# Handle UTC offset format (e.g. +0100, -0500)
|
|
60
|
+
sign = 1 if tz.startswith('+') else -1
|
|
61
|
+
hours = int(tz[1:3])
|
|
62
|
+
minutes = int(tz[3:]) if len(tz) > 3 else 0
|
|
63
|
+
return dt_timezone(sign * timedelta(hours=hours, minutes=minutes))
|
|
64
|
+
else:
|
|
65
|
+
# Handle named timezone (e.g. UTC, Europe/London)
|
|
66
|
+
try:
|
|
67
|
+
return ZoneInfo(tz)
|
|
68
|
+
except Exception as e:
|
|
69
|
+
raise ValueError(f"Invalid timezone {tz}: {e}")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _find_timestamp_columns(headers: list[str],
|
|
73
|
+
timestamp_column: str | None = None,
|
|
74
|
+
date_column: str | None = None,
|
|
75
|
+
time_column: str | None = None) -> tuple[int | None, int | None, int | None]:
|
|
76
|
+
"""
|
|
77
|
+
Find timestamp-related column indices in headers.
|
|
78
|
+
|
|
79
|
+
:param headers: List of column headers (already lowercased)
|
|
80
|
+
:param timestamp_column: Optional specific timestamp column name
|
|
81
|
+
:param date_column: Optional date column name (when split into date+time)
|
|
82
|
+
:param time_column: Optional time column name (when split into date+time)
|
|
83
|
+
:return: Tuple of (timestamp_idx, date_idx, time_idx)
|
|
84
|
+
:raises ValueError: If required columns are not found
|
|
85
|
+
"""
|
|
86
|
+
timestamp_idx = None
|
|
87
|
+
date_idx = None
|
|
88
|
+
time_idx = None
|
|
89
|
+
|
|
90
|
+
if date_column and time_column:
|
|
91
|
+
try:
|
|
92
|
+
date_idx = headers.index(date_column.lower())
|
|
93
|
+
time_idx = headers.index(time_column.lower())
|
|
94
|
+
except ValueError:
|
|
95
|
+
raise ValueError(f"Date/time columns not found: {date_column}/{time_column}")
|
|
96
|
+
else:
|
|
97
|
+
timestamp_col = timestamp_column.lower() if timestamp_column else None
|
|
98
|
+
if timestamp_col:
|
|
99
|
+
try:
|
|
100
|
+
timestamp_idx = headers.index(timestamp_col)
|
|
101
|
+
except ValueError:
|
|
102
|
+
raise ValueError(f"Timestamp column not found: {timestamp_col}")
|
|
103
|
+
else:
|
|
104
|
+
# Try common names (ts_event / ts_recv are Databento's nanosecond timestamps)
|
|
105
|
+
for col in ['timestamp', 'time', 'date', 'ts_event', 'ts_recv']:
|
|
106
|
+
try:
|
|
107
|
+
timestamp_idx = headers.index(col)
|
|
108
|
+
break
|
|
109
|
+
except ValueError:
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if timestamp_idx is None:
|
|
113
|
+
raise ValueError("Timestamp column not found!")
|
|
114
|
+
|
|
115
|
+
return timestamp_idx, date_idx, time_idx
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _find_ohlcv_columns(headers: list[str]) -> tuple[int, int, int, int, int]:
|
|
119
|
+
"""
|
|
120
|
+
Find OHLCV column indices in headers.
|
|
121
|
+
|
|
122
|
+
:param headers: List of column headers (already lowercased)
|
|
123
|
+
:return: Tuple of (open_idx, high_idx, low_idx, close_idx, volume_idx)
|
|
124
|
+
:raises ValueError: If required columns are not found
|
|
125
|
+
"""
|
|
126
|
+
try:
|
|
127
|
+
o_idx = headers.index('open')
|
|
128
|
+
h_idx = headers.index('high')
|
|
129
|
+
l_idx = headers.index('low')
|
|
130
|
+
c_idx = headers.index('close')
|
|
131
|
+
v_idx = headers.index('volume')
|
|
132
|
+
except ValueError as e:
|
|
133
|
+
raise ValueError(f"Missing required column: {str(e)}")
|
|
134
|
+
|
|
135
|
+
return o_idx, h_idx, l_idx, c_idx, v_idx
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_timestamp(ts_str: str, timestamp_format: str | None = None, timezone=None) -> int:
|
|
139
|
+
"""
|
|
140
|
+
Parse timestamp string to Unix timestamp.
|
|
141
|
+
|
|
142
|
+
:param ts_str: Timestamp string to parse
|
|
143
|
+
:param timestamp_format: Optional specific datetime format for parsing
|
|
144
|
+
:param timezone: Optional timezone to apply to the parsed datetime
|
|
145
|
+
:return: Unix timestamp as integer
|
|
146
|
+
:raises ValueError: If timestamp cannot be parsed
|
|
147
|
+
"""
|
|
148
|
+
# Handle numeric timestamps (seconds / ms / us / ns since epoch)
|
|
149
|
+
if ts_str.isdigit():
|
|
150
|
+
timestamp = int(ts_str)
|
|
151
|
+
# Repeatedly downscale by 1000 until we land in the valid seconds range.
|
|
152
|
+
# Covers ms (13 digits), us (16 digits) and ns (19 digits, e.g. Databento).
|
|
153
|
+
while timestamp > 253402300799: # 9999-12-31 23:59:59
|
|
154
|
+
timestamp //= 1000
|
|
155
|
+
return timestamp
|
|
156
|
+
|
|
157
|
+
# Truncate sub-microsecond fractional digits (Databento emits nanoseconds,
|
|
158
|
+
# e.g. "2026-02-23T00:00:00.000000000Z" — Python's %f only matches 1-6 digits).
|
|
159
|
+
ts_str = re.sub(r'(\.\d{6})\d+', r'\1', ts_str)
|
|
160
|
+
|
|
161
|
+
# Parse datetime string
|
|
162
|
+
dt = None
|
|
163
|
+
if timestamp_format:
|
|
164
|
+
dt = datetime.strptime(ts_str, timestamp_format)
|
|
165
|
+
else:
|
|
166
|
+
# Try common formats
|
|
167
|
+
for fmt in [
|
|
168
|
+
'%Y-%m-%d %H:%M:%S%z', # 2024-01-08 19:00:00+0000
|
|
169
|
+
'%Y-%m-%d %H:%M:%S%Z', # 2024-01-08 19:00:00UTC
|
|
170
|
+
'%Y-%m-%dT%H:%M:%S%z', # 2024-01-08T19:00:00+0000
|
|
171
|
+
'%Y-%m-%dT%H:%M:%S.%f%z', # 2024-01-08T19:00:00.123456+0000
|
|
172
|
+
'%Y-%m-%dT%H:%M:%S.%fZ', # 2026-02-23T00:00:00.000000Z (Databento)
|
|
173
|
+
'%Y-%m-%d %H:%M:%S',
|
|
174
|
+
'%Y/%m/%d %H:%M:%S',
|
|
175
|
+
'%d.%m.%Y %H:%M:%S',
|
|
176
|
+
'%Y-%m-%dT%H:%M:%S',
|
|
177
|
+
'%Y-%m-%dT%H:%M:%SZ', # ISO with Z
|
|
178
|
+
'%Y-%m-%d %H:%M',
|
|
179
|
+
'%Y%m%d %H:%M:%S'
|
|
180
|
+
]:
|
|
181
|
+
try:
|
|
182
|
+
dt = datetime.strptime(ts_str, fmt)
|
|
183
|
+
break
|
|
184
|
+
except ValueError:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
if dt is None:
|
|
188
|
+
raise ValueError(f"Could not parse timestamp: {ts_str}")
|
|
189
|
+
|
|
190
|
+
# Apply timezone parameter only if the datetime doesn't already have timezone info
|
|
191
|
+
# This preserves timezone information from the timestamp string itself
|
|
192
|
+
if timezone and dt is not None and dt.tzinfo is None:
|
|
193
|
+
dt = dt.replace(tzinfo=timezone)
|
|
194
|
+
|
|
195
|
+
assert dt is not None
|
|
196
|
+
return int(dt.timestamp())
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class OHLCVWriter:
|
|
200
|
+
"""
|
|
201
|
+
Binary OHLCV data writer using direct file operations
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
__slots__ = ('path', '_file', '_size', '_start_timestamp', '_interval', '_current_pos', '_last_timestamp',
|
|
205
|
+
'_price_changes', '_price_decimals', '_last_close', '_analyzed_tick_size',
|
|
206
|
+
'_analyzed_price_scale', '_analyzed_min_move', '_confidence',
|
|
207
|
+
'_volume_max_decimals', '_volume_count', '_volume_dust_count',
|
|
208
|
+
'_trading_hours', '_analyzed_opening_hours', '_timestamp_offsets', '_analyzed_timezone',
|
|
209
|
+
'_truncate',
|
|
210
|
+
'_extra_file', '_extra_writer', '_extra_headers', '_extra_row_count')
|
|
211
|
+
|
|
212
|
+
def __init__(self, path: str | Path, truncate: bool = False):
|
|
213
|
+
self.path: str = str(path)
|
|
214
|
+
self._file: BufferedWriter | BufferedRandom | None = None
|
|
215
|
+
self._truncate: bool = truncate
|
|
216
|
+
self._size: int = 0
|
|
217
|
+
self._start_timestamp: int | None = None
|
|
218
|
+
self._interval: int | None = None
|
|
219
|
+
self._current_pos: int = 0
|
|
220
|
+
self._last_timestamp: int | None = None
|
|
221
|
+
# Tick size analysis
|
|
222
|
+
self._price_changes: list[float] = []
|
|
223
|
+
self._price_decimals: set[int] = set()
|
|
224
|
+
self._last_close: float | None = None
|
|
225
|
+
self._analyzed_tick_size: float | None = None
|
|
226
|
+
self._analyzed_price_scale: int | None = None
|
|
227
|
+
self._analyzed_min_move: int | None = None
|
|
228
|
+
self._confidence: float = 0.0
|
|
229
|
+
# Quantity step (mincontract) analysis
|
|
230
|
+
self._volume_max_decimals: int = 0
|
|
231
|
+
self._volume_count: int = 0
|
|
232
|
+
self._volume_dust_count: int = 0
|
|
233
|
+
# Trading hours analysis
|
|
234
|
+
self._trading_hours: dict[tuple[int, int], int] = {} # (weekday, hour) -> count
|
|
235
|
+
self._analyzed_opening_hours: list | None = None
|
|
236
|
+
# Extra fields sidecar CSV
|
|
237
|
+
self._extra_file = None
|
|
238
|
+
self._extra_writer = None
|
|
239
|
+
self._extra_headers: list[str] | None = None
|
|
240
|
+
self._extra_row_count: int = 0
|
|
241
|
+
|
|
242
|
+
def __enter__(self):
|
|
243
|
+
self.open()
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
247
|
+
self.close()
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def is_open(self) -> bool:
|
|
251
|
+
"""
|
|
252
|
+
Check if file is open
|
|
253
|
+
"""
|
|
254
|
+
return self._file is not None
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def size(self) -> int:
|
|
258
|
+
"""
|
|
259
|
+
Number of records in the file
|
|
260
|
+
"""
|
|
261
|
+
return self._size
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def start_timestamp(self) -> int | None:
|
|
265
|
+
"""
|
|
266
|
+
Timestamp of the first record
|
|
267
|
+
"""
|
|
268
|
+
return self._start_timestamp
|
|
269
|
+
|
|
270
|
+
@property
|
|
271
|
+
def start_datetime(self) -> datetime:
|
|
272
|
+
"""
|
|
273
|
+
Datetime of the first record
|
|
274
|
+
"""
|
|
275
|
+
assert self._start_timestamp is not None
|
|
276
|
+
return datetime.fromtimestamp(self._start_timestamp, UTC)
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def end_timestamp(self) -> int | None:
|
|
280
|
+
"""
|
|
281
|
+
Timestamp of the last record
|
|
282
|
+
"""
|
|
283
|
+
if self._start_timestamp is None or self._interval is None:
|
|
284
|
+
return None
|
|
285
|
+
return self._start_timestamp + self._interval * (self._size - 1)
|
|
286
|
+
|
|
287
|
+
@property
|
|
288
|
+
def end_datetime(self) -> datetime | None:
|
|
289
|
+
"""
|
|
290
|
+
Datetime of the last record
|
|
291
|
+
"""
|
|
292
|
+
ts = self.end_timestamp
|
|
293
|
+
if ts is None:
|
|
294
|
+
return None
|
|
295
|
+
return datetime.fromtimestamp(ts, UTC)
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def interval(self) -> int | None:
|
|
299
|
+
"""
|
|
300
|
+
Interval between records
|
|
301
|
+
"""
|
|
302
|
+
return self._interval
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def analyzed_tick_size(self) -> float | None:
|
|
306
|
+
"""
|
|
307
|
+
Automatically detected tick size from price data
|
|
308
|
+
"""
|
|
309
|
+
if self._analyzed_tick_size is None and len(self._price_changes) >= 10:
|
|
310
|
+
self._analyze_tick_size()
|
|
311
|
+
return self._analyzed_tick_size
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def analyzed_price_scale(self) -> int | None:
|
|
315
|
+
"""
|
|
316
|
+
Automatically detected price scale from price data
|
|
317
|
+
"""
|
|
318
|
+
if self._analyzed_price_scale is None and len(self._price_changes) >= 10:
|
|
319
|
+
self._analyze_tick_size()
|
|
320
|
+
return self._analyzed_price_scale
|
|
321
|
+
|
|
322
|
+
@property
|
|
323
|
+
def analyzed_min_move(self) -> int | None:
|
|
324
|
+
"""
|
|
325
|
+
Automatically detected min move (usually 1)
|
|
326
|
+
"""
|
|
327
|
+
if self._analyzed_min_move is None and len(self._price_changes) >= 10:
|
|
328
|
+
self._analyze_tick_size()
|
|
329
|
+
return self._analyzed_min_move
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def tick_analysis_confidence(self) -> float:
|
|
333
|
+
"""
|
|
334
|
+
Confidence of tick size analysis (0.0 to 1.0)
|
|
335
|
+
"""
|
|
336
|
+
if self._confidence == 0.0 and len(self._price_changes) >= 10:
|
|
337
|
+
self._analyze_tick_size()
|
|
338
|
+
return self._confidence
|
|
339
|
+
|
|
340
|
+
@property
|
|
341
|
+
def analyzed_opening_hours(self) -> list | None:
|
|
342
|
+
"""
|
|
343
|
+
Automatically detected opening hours from trading activity
|
|
344
|
+
Returns list of SymInfoInterval tuples or None if not enough data
|
|
345
|
+
"""
|
|
346
|
+
if self._analyzed_opening_hours is None and self._has_enough_data_for_opening_hours():
|
|
347
|
+
self._analyze_opening_hours()
|
|
348
|
+
return self._analyzed_opening_hours
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def analyzed_qty_step(self) -> float | None:
|
|
352
|
+
"""
|
|
353
|
+
Automatically detected quantity step (``mincontract`` candidate) from volume data.
|
|
354
|
+
|
|
355
|
+
Every bar volume is a sum of trade quantities on the exchange's lot
|
|
356
|
+
grid, so the maximum decimal precision seen across the written volumes
|
|
357
|
+
bounds the lot step from below. Returns ``None`` without enough
|
|
358
|
+
positive-volume bars or when the volumes carry float dust (which means
|
|
359
|
+
they were computed, not exchange-native).
|
|
360
|
+
"""
|
|
361
|
+
if self._volume_count < _QTY_STEP_MIN_SAMPLES:
|
|
362
|
+
return None
|
|
363
|
+
if self._volume_dust_count * 20 > self._volume_count: # >5% dusty samples
|
|
364
|
+
return None
|
|
365
|
+
return 10.0 ** -self._volume_max_decimals
|
|
366
|
+
|
|
367
|
+
def open(self) -> 'OHLCVWriter':
|
|
368
|
+
"""
|
|
369
|
+
Open file for writing
|
|
370
|
+
"""
|
|
371
|
+
# If truncate is True, always open in write mode to clear existing data
|
|
372
|
+
if self._truncate:
|
|
373
|
+
self._file = open(self.path, 'wb+')
|
|
374
|
+
else:
|
|
375
|
+
# Open in rb+ mode to allow both reading and writing
|
|
376
|
+
self._file = open(self.path, 'rb+') if os.path.exists(self.path) else open(self.path, 'wb+')
|
|
377
|
+
self._size = os.path.getsize(self.path) // RECORD_SIZE
|
|
378
|
+
|
|
379
|
+
# Read initial metadata if file exists
|
|
380
|
+
assert self._file is not None
|
|
381
|
+
if self._size >= 2:
|
|
382
|
+
self._file.seek(0)
|
|
383
|
+
data = self._file.read(4)
|
|
384
|
+
first_timestamp = struct.unpack('I', data)[0]
|
|
385
|
+
self._file.seek(RECORD_SIZE)
|
|
386
|
+
data = self._file.read(4)
|
|
387
|
+
second_timestamp = struct.unpack('I', data)[0]
|
|
388
|
+
self._start_timestamp = first_timestamp
|
|
389
|
+
self._interval = second_timestamp - first_timestamp
|
|
390
|
+
assert self._interval is not None
|
|
391
|
+
self._last_timestamp = first_timestamp + self._interval * (self._size - 1)
|
|
392
|
+
|
|
393
|
+
# Position at end for appending
|
|
394
|
+
self._file.seek(0, os.SEEK_END)
|
|
395
|
+
self._current_pos = self._size
|
|
396
|
+
|
|
397
|
+
# Collect trading hours from existing data for analysis
|
|
398
|
+
if self._size > 0 and not self._truncate:
|
|
399
|
+
self._collect_existing_trading_hours()
|
|
400
|
+
|
|
401
|
+
# Check for existing extra fields sidecar
|
|
402
|
+
extra_path = Path(self.path).with_suffix('.extra.csv')
|
|
403
|
+
if extra_path.exists():
|
|
404
|
+
with open(extra_path, 'r', newline='') as ef:
|
|
405
|
+
reader = csv.reader(ef)
|
|
406
|
+
headers = next(reader, None)
|
|
407
|
+
if headers:
|
|
408
|
+
self._extra_headers = headers
|
|
409
|
+
self._extra_row_count = sum(1 for _ in reader)
|
|
410
|
+
|
|
411
|
+
return self
|
|
412
|
+
|
|
413
|
+
def write(self, candle: OHLCV) -> None:
|
|
414
|
+
"""
|
|
415
|
+
Write a single OHLCV candle at current position.
|
|
416
|
+
If there is a gap between current and previous timestamp,
|
|
417
|
+
fills it with the previous close price and -1 volume to indicate gap filling.
|
|
418
|
+
|
|
419
|
+
:param candle: OHLCV data to write
|
|
420
|
+
"""
|
|
421
|
+
if self._file is None:
|
|
422
|
+
raise IOError("File not opened!")
|
|
423
|
+
|
|
424
|
+
if self._size == 0:
|
|
425
|
+
self._start_timestamp = candle.timestamp
|
|
426
|
+
elif self._size == 1:
|
|
427
|
+
# First interval detection
|
|
428
|
+
assert self._start_timestamp is not None
|
|
429
|
+
interval = candle.timestamp - self._start_timestamp
|
|
430
|
+
self._interval = interval
|
|
431
|
+
if interval == 0:
|
|
432
|
+
raise ValueError(
|
|
433
|
+
f"Duplicate timestamp {candle.timestamp} on consecutive rows. "
|
|
434
|
+
f"Input data must contain a single bar per timestamp "
|
|
435
|
+
f"(e.g. for Databento, pre-filter to one publisher_id / instrument_id).")
|
|
436
|
+
if interval < 0:
|
|
437
|
+
raise ValueError(
|
|
438
|
+
f"Timestamps must be in chronological order. "
|
|
439
|
+
f"Got {candle.timestamp} after {self._start_timestamp}.")
|
|
440
|
+
elif self._size >= 2: # Changed from elif self._size == 2: to properly handle all cases
|
|
441
|
+
# Check chronological order
|
|
442
|
+
if self._last_timestamp is not None and candle.timestamp == self._last_timestamp:
|
|
443
|
+
raise ValueError(
|
|
444
|
+
f"Duplicate timestamp {candle.timestamp} on consecutive rows. "
|
|
445
|
+
f"Input data must contain a single bar per timestamp "
|
|
446
|
+
f"(e.g. for Databento, pre-filter to one publisher_id / instrument_id).")
|
|
447
|
+
if self._last_timestamp is not None and candle.timestamp < self._last_timestamp:
|
|
448
|
+
raise ValueError(
|
|
449
|
+
f"Timestamps must be in chronological order. "
|
|
450
|
+
f"Got {candle.timestamp} after {self._last_timestamp}.")
|
|
451
|
+
|
|
452
|
+
# Check if we found a smaller interval (indicates initial interval was wrong due to gap)
|
|
453
|
+
if self._interval is not None and self._last_timestamp is not None:
|
|
454
|
+
current_interval = candle.timestamp - self._last_timestamp
|
|
455
|
+
|
|
456
|
+
# If we find a smaller interval, the initial one was wrong (had a gap)
|
|
457
|
+
if 0 < current_interval < self._interval:
|
|
458
|
+
# Rebuild file with correct interval
|
|
459
|
+
self._rebuild_with_correct_interval(current_interval)
|
|
460
|
+
# Now write the current candle with the corrected setup
|
|
461
|
+
self.write(candle)
|
|
462
|
+
return
|
|
463
|
+
|
|
464
|
+
# Calculate expected timestamp and fill gaps
|
|
465
|
+
if self._interval is not None and self._last_timestamp is not None:
|
|
466
|
+
expected_ts = self._last_timestamp + self._interval
|
|
467
|
+
|
|
468
|
+
# Fill gap if needed
|
|
469
|
+
if candle.timestamp > expected_ts:
|
|
470
|
+
# Get previous candle's close price
|
|
471
|
+
self._file.seek((self._current_pos - 1) * RECORD_SIZE)
|
|
472
|
+
prev_record = self._file.read(RECORD_SIZE)
|
|
473
|
+
prev_data = struct.unpack(STRUCT_FORMAT, prev_record)
|
|
474
|
+
prev_close = prev_data[4] # 4th index is close price
|
|
475
|
+
|
|
476
|
+
# Fill gap with previous close and -1 volume (gap indicator)
|
|
477
|
+
while expected_ts < candle.timestamp:
|
|
478
|
+
gap_data = struct.pack(STRUCT_FORMAT,
|
|
479
|
+
expected_ts, prev_close, prev_close,
|
|
480
|
+
prev_close, prev_close, -1.0)
|
|
481
|
+
self._file.seek(self._current_pos * RECORD_SIZE)
|
|
482
|
+
self._file.write(gap_data)
|
|
483
|
+
self._write_extra_gap()
|
|
484
|
+
self._current_pos += 1
|
|
485
|
+
self._size = max(self._size, self._current_pos)
|
|
486
|
+
expected_ts += self._interval
|
|
487
|
+
|
|
488
|
+
# Write actual data
|
|
489
|
+
self._file.seek(self._current_pos * RECORD_SIZE)
|
|
490
|
+
data = struct.pack(STRUCT_FORMAT,
|
|
491
|
+
candle.timestamp, candle.open, candle.high,
|
|
492
|
+
candle.low, candle.close, candle.volume)
|
|
493
|
+
self._file.write(data)
|
|
494
|
+
self._file.flush()
|
|
495
|
+
|
|
496
|
+
# Write extra fields to sidecar CSV
|
|
497
|
+
self._write_extra_data(candle.extra_fields)
|
|
498
|
+
|
|
499
|
+
# Collect data for tick size analysis
|
|
500
|
+
self._collect_price_data(candle)
|
|
501
|
+
|
|
502
|
+
# Collect volume data for quantity step analysis
|
|
503
|
+
self._collect_volume_data(candle)
|
|
504
|
+
|
|
505
|
+
# Collect trading hours data
|
|
506
|
+
self._collect_trading_hours(candle)
|
|
507
|
+
|
|
508
|
+
self._last_timestamp = candle.timestamp
|
|
509
|
+
self._current_pos += 1
|
|
510
|
+
self._size = max(self._size, self._current_pos)
|
|
511
|
+
|
|
512
|
+
def seek_to_timestamp(self, timestamp: int) -> None:
|
|
513
|
+
"""
|
|
514
|
+
Move write position to specific timestamp.
|
|
515
|
+
Uses interval between bars to calculate position.
|
|
516
|
+
"""
|
|
517
|
+
if self._interval is None or self._start_timestamp is None:
|
|
518
|
+
return
|
|
519
|
+
|
|
520
|
+
if timestamp < self._start_timestamp:
|
|
521
|
+
raise ValueError("Timestamp before start of data")
|
|
522
|
+
|
|
523
|
+
record_num = (timestamp - self._start_timestamp) // self._interval
|
|
524
|
+
self.seek(int(record_num))
|
|
525
|
+
|
|
526
|
+
def seek(self, position: int) -> None:
|
|
527
|
+
"""
|
|
528
|
+
Move write position to specific record number
|
|
529
|
+
"""
|
|
530
|
+
if position < 0:
|
|
531
|
+
raise ValueError("Negative position not allowed")
|
|
532
|
+
assert self._file is not None
|
|
533
|
+
|
|
534
|
+
self._current_pos = position
|
|
535
|
+
self._file.seek(position * RECORD_SIZE)
|
|
536
|
+
|
|
537
|
+
def truncate(self) -> None:
|
|
538
|
+
"""
|
|
539
|
+
Truncate file at current position.
|
|
540
|
+
All data after current position will be deleted.
|
|
541
|
+
"""
|
|
542
|
+
if self._file is None:
|
|
543
|
+
raise IOError("File not opened!")
|
|
544
|
+
|
|
545
|
+
# Calculate new size in bytes
|
|
546
|
+
new_size = self._current_pos * RECORD_SIZE
|
|
547
|
+
|
|
548
|
+
# Truncate the file
|
|
549
|
+
self._file.truncate(new_size)
|
|
550
|
+
self._size = self._current_pos
|
|
551
|
+
|
|
552
|
+
# Update interval if we deleted too much
|
|
553
|
+
if self._size < 2:
|
|
554
|
+
self._interval = None
|
|
555
|
+
if self._size == 0:
|
|
556
|
+
self._start_timestamp = None
|
|
557
|
+
|
|
558
|
+
# Clean up extra fields sidecar on full truncate
|
|
559
|
+
if self._current_pos == 0:
|
|
560
|
+
self._close_extra_csv()
|
|
561
|
+
extra_path = Path(self.path).with_suffix('.extra.csv')
|
|
562
|
+
if extra_path.exists():
|
|
563
|
+
extra_path.unlink()
|
|
564
|
+
self._extra_headers = None
|
|
565
|
+
self._extra_row_count = 0
|
|
566
|
+
|
|
567
|
+
def close(self):
|
|
568
|
+
"""
|
|
569
|
+
Close the file
|
|
570
|
+
"""
|
|
571
|
+
if self._file:
|
|
572
|
+
self._file.close()
|
|
573
|
+
self._file = None
|
|
574
|
+
self._close_extra_csv()
|
|
575
|
+
|
|
576
|
+
def _close_extra_csv(self) -> None:
|
|
577
|
+
"""Close the extra fields sidecar CSV if open."""
|
|
578
|
+
if self._extra_file:
|
|
579
|
+
self._extra_file.close()
|
|
580
|
+
self._extra_file = None
|
|
581
|
+
self._extra_writer = None
|
|
582
|
+
|
|
583
|
+
def _open_extra_csv(self, headers: list[str]) -> None:
|
|
584
|
+
"""Open the extra fields sidecar CSV for writing."""
|
|
585
|
+
extra_path = Path(self.path).with_suffix('.extra.csv')
|
|
586
|
+
|
|
587
|
+
if (self._extra_headers == headers
|
|
588
|
+
and self._extra_row_count <= self._current_pos):
|
|
589
|
+
# Compatible header exists, append and pad if needed
|
|
590
|
+
self._extra_file = open(extra_path, 'a', newline='')
|
|
591
|
+
self._extra_writer = csv.writer(self._extra_file)
|
|
592
|
+
empty = [''] * len(headers)
|
|
593
|
+
for _ in range(self._current_pos - self._extra_row_count):
|
|
594
|
+
self._extra_writer.writerow(empty)
|
|
595
|
+
self._extra_row_count = self._current_pos
|
|
596
|
+
else:
|
|
597
|
+
# New file or incompatible header
|
|
598
|
+
self._extra_file = open(extra_path, 'w', newline='')
|
|
599
|
+
self._extra_writer = csv.writer(self._extra_file)
|
|
600
|
+
self._extra_headers = headers
|
|
601
|
+
self._extra_writer.writerow(headers)
|
|
602
|
+
empty = [''] * len(headers)
|
|
603
|
+
for _ in range(self._current_pos):
|
|
604
|
+
self._extra_writer.writerow(empty)
|
|
605
|
+
self._extra_row_count = self._current_pos
|
|
606
|
+
|
|
607
|
+
def _write_extra_gap(self) -> None:
|
|
608
|
+
"""Write an empty row to the extra CSV for a gap-fill position."""
|
|
609
|
+
if self._extra_writer is not None:
|
|
610
|
+
self._extra_writer.writerow([''] * len(self._extra_headers))
|
|
611
|
+
self._extra_row_count += 1
|
|
612
|
+
|
|
613
|
+
def _write_extra_data(self, extra_fields: dict | None) -> None:
|
|
614
|
+
"""Write extra fields data row to the sidecar CSV."""
|
|
615
|
+
if extra_fields and self._extra_writer is None:
|
|
616
|
+
self._open_extra_csv(list(extra_fields.keys()))
|
|
617
|
+
if self._extra_writer is not None:
|
|
618
|
+
if extra_fields:
|
|
619
|
+
row = []
|
|
620
|
+
for h in self._extra_headers:
|
|
621
|
+
v = extra_fields.get(h)
|
|
622
|
+
if v is None:
|
|
623
|
+
row.append('')
|
|
624
|
+
elif isinstance(v, float):
|
|
625
|
+
row.append(_format_float(v))
|
|
626
|
+
else:
|
|
627
|
+
row.append(str(v))
|
|
628
|
+
self._extra_writer.writerow(row)
|
|
629
|
+
else:
|
|
630
|
+
self._extra_writer.writerow([''] * len(self._extra_headers))
|
|
631
|
+
self._extra_row_count += 1
|
|
632
|
+
self._extra_file.flush()
|
|
633
|
+
|
|
634
|
+
def _collect_price_data(self, candle: OHLCV) -> None:
|
|
635
|
+
"""
|
|
636
|
+
Collect price data for tick size analysis during writing.
|
|
637
|
+
"""
|
|
638
|
+
# Collect price changes
|
|
639
|
+
if self._last_close is not None:
|
|
640
|
+
change = abs(candle.close - self._last_close)
|
|
641
|
+
if change > 0 and len(self._price_changes) < 1000: # Limit to 1000 samples
|
|
642
|
+
self._price_changes.append(change)
|
|
643
|
+
|
|
644
|
+
# Collect decimal places
|
|
645
|
+
for price in [candle.open, candle.high, candle.low, candle.close]:
|
|
646
|
+
if price != int(price): # Has decimal component
|
|
647
|
+
price_str = f"{price:.15f}".rstrip('0').rstrip('.')
|
|
648
|
+
if '.' in price_str:
|
|
649
|
+
decimals = len(price_str.split('.')[1])
|
|
650
|
+
self._price_decimals.add(decimals)
|
|
651
|
+
|
|
652
|
+
self._last_close = candle.close
|
|
653
|
+
|
|
654
|
+
def _collect_volume_data(self, candle: OHLCV) -> None:
|
|
655
|
+
"""
|
|
656
|
+
Collect volume decimal statistics for quantity step analysis during writing.
|
|
657
|
+
|
|
658
|
+
Must run on the original float64 volumes: the file stores float32,
|
|
659
|
+
whose ~7 significant digits destroy a small lot step on large volumes,
|
|
660
|
+
so the analysis cannot be redone from the file afterwards.
|
|
661
|
+
"""
|
|
662
|
+
volume = candle.volume
|
|
663
|
+
if volume <= 0.0: # empty bars and -1.0 gap fills carry no quantity information
|
|
664
|
+
return
|
|
665
|
+
vol_str = str(volume) # shortest round-trip repr, no float dust
|
|
666
|
+
if 'e' in vol_str or 'E' in vol_str:
|
|
667
|
+
vol_str = f"{volume:.20f}".rstrip('0')
|
|
668
|
+
decimals = len(vol_str.split('.')[1].rstrip('0')) if '.' in vol_str else 0
|
|
669
|
+
if decimals > _QTY_STEP_MAX_DECIMALS:
|
|
670
|
+
self._volume_dust_count += 1
|
|
671
|
+
return
|
|
672
|
+
self._volume_count += 1
|
|
673
|
+
if decimals > self._volume_max_decimals:
|
|
674
|
+
self._volume_max_decimals = decimals
|
|
675
|
+
|
|
676
|
+
def _analyze_tick_size(self) -> None:
|
|
677
|
+
"""
|
|
678
|
+
Analyze collected price data to determine tick size using multiple methods.
|
|
679
|
+
"""
|
|
680
|
+
if not self._price_changes:
|
|
681
|
+
# No data, use defaults
|
|
682
|
+
self._analyzed_tick_size = 0.01
|
|
683
|
+
self._analyzed_price_scale = 100
|
|
684
|
+
self._analyzed_min_move = 1
|
|
685
|
+
self._confidence = 0.1
|
|
686
|
+
return
|
|
687
|
+
|
|
688
|
+
# Try histogram-based method first for better noise handling
|
|
689
|
+
histogram_tick = self._calculate_histogram_tick()
|
|
690
|
+
|
|
691
|
+
if histogram_tick[0] > 0 and histogram_tick[1] > 0.7:
|
|
692
|
+
# High confidence histogram result, use it directly
|
|
693
|
+
self._analyzed_tick_size = histogram_tick[0]
|
|
694
|
+
self._analyzed_price_scale = int(round(1.0 / histogram_tick[0]))
|
|
695
|
+
self._analyzed_min_move = 1
|
|
696
|
+
self._confidence = histogram_tick[1]
|
|
697
|
+
return
|
|
698
|
+
|
|
699
|
+
# Fall back to other methods
|
|
700
|
+
# Method 1: Most frequent small change
|
|
701
|
+
freq_tick = self._calculate_frequency_tick()
|
|
702
|
+
|
|
703
|
+
# Method 2: Decimal places analysis
|
|
704
|
+
decimal_tick = self._calculate_decimal_tick()
|
|
705
|
+
|
|
706
|
+
# Combine methods with weighted confidence (no GCD)
|
|
707
|
+
tick_size, confidence = self._combine_tick_estimates(freq_tick, decimal_tick)
|
|
708
|
+
|
|
709
|
+
# Calculate price scale and min move
|
|
710
|
+
if tick_size > 0:
|
|
711
|
+
self._analyzed_tick_size = tick_size
|
|
712
|
+
self._analyzed_price_scale = int(round(1.0 / tick_size))
|
|
713
|
+
self._analyzed_min_move = 1
|
|
714
|
+
self._confidence = confidence
|
|
715
|
+
else:
|
|
716
|
+
# Fallback to defaults
|
|
717
|
+
self._analyzed_tick_size = 0.01
|
|
718
|
+
self._analyzed_price_scale = 100
|
|
719
|
+
self._analyzed_min_move = 1
|
|
720
|
+
self._confidence = 0.1
|
|
721
|
+
|
|
722
|
+
def _calculate_frequency_tick(self) -> tuple[float, float]:
|
|
723
|
+
"""
|
|
724
|
+
Calculate tick size based on most frequent small changes.
|
|
725
|
+
Returns (tick_size, confidence)
|
|
726
|
+
"""
|
|
727
|
+
if len(self._price_changes) < 10:
|
|
728
|
+
return 0, 0
|
|
729
|
+
|
|
730
|
+
# Apply float32 filtering first
|
|
731
|
+
filtered_changes = []
|
|
732
|
+
for c in self._price_changes[:100]:
|
|
733
|
+
if c > 0:
|
|
734
|
+
# Convert to float32 and back
|
|
735
|
+
float32_val = struct.unpack('f', struct.pack('f', c))[0]
|
|
736
|
+
# Round to reasonable precision for float32
|
|
737
|
+
rounded = round(float32_val, 6)
|
|
738
|
+
if rounded > 0:
|
|
739
|
+
filtered_changes.append(rounded)
|
|
740
|
+
|
|
741
|
+
if len(filtered_changes) < 5:
|
|
742
|
+
return 0, 0
|
|
743
|
+
|
|
744
|
+
# Find most frequent changes
|
|
745
|
+
counter = Counter(filtered_changes)
|
|
746
|
+
most_common = counter.most_common(10)
|
|
747
|
+
|
|
748
|
+
if not most_common:
|
|
749
|
+
return 0, 0
|
|
750
|
+
|
|
751
|
+
# Find GCD of frequent changes to get base tick
|
|
752
|
+
frequent_changes = [change for change, count in most_common if count >= 2]
|
|
753
|
+
if len(frequent_changes) >= 2:
|
|
754
|
+
# Convert to integers for GCD
|
|
755
|
+
scale = 1000000 # 6 decimal places
|
|
756
|
+
int_changes = [int(round(c * scale)) for c in frequent_changes]
|
|
757
|
+
|
|
758
|
+
# Calculate GCD
|
|
759
|
+
result = int_changes[0]
|
|
760
|
+
for val in int_changes[1:]:
|
|
761
|
+
result = math_gcd(result, val)
|
|
762
|
+
|
|
763
|
+
tick_size = result / scale
|
|
764
|
+
|
|
765
|
+
# Confidence based on how many changes match this tick
|
|
766
|
+
matches = sum(1 for c in filtered_changes
|
|
767
|
+
if abs(round(c / tick_size) * tick_size - c) < tick_size * 0.1)
|
|
768
|
+
confidence = min(matches / len(filtered_changes), 1.0)
|
|
769
|
+
return tick_size, confidence * 0.7 # Medium weight
|
|
770
|
+
|
|
771
|
+
return 0, 0
|
|
772
|
+
|
|
773
|
+
def _calculate_histogram_tick(self) -> tuple[float, float]:
|
|
774
|
+
"""
|
|
775
|
+
Calculate tick size using histogram-based clustering approach.
|
|
776
|
+
This method is robust to float32 noise.
|
|
777
|
+
Returns (tick_size, confidence)
|
|
778
|
+
"""
|
|
779
|
+
if len(self._price_changes) < 10:
|
|
780
|
+
return 0, 0
|
|
781
|
+
|
|
782
|
+
# Common tick sizes to test (from 1 to 0.00001)
|
|
783
|
+
candidate_ticks = [
|
|
784
|
+
1.0, 0.5, 0.25, 0.1, 0.05, 0.01, 0.005, 0.001,
|
|
785
|
+
0.0005, 0.0001, 0.00005, 0.00001, 0.000001
|
|
786
|
+
]
|
|
787
|
+
|
|
788
|
+
best_tick = 0
|
|
789
|
+
best_score = 0
|
|
790
|
+
|
|
791
|
+
# Filter out zero changes and convert to float32 precision
|
|
792
|
+
changes = []
|
|
793
|
+
for change in self._price_changes[:200]: # Use more samples for histogram
|
|
794
|
+
if change > 0:
|
|
795
|
+
# Round to float32 precision
|
|
796
|
+
float32_val = struct.unpack('f', struct.pack('f', change))[0]
|
|
797
|
+
changes.append(float32_val)
|
|
798
|
+
|
|
799
|
+
if len(changes) < 5:
|
|
800
|
+
return 0, 0
|
|
801
|
+
|
|
802
|
+
# Get min non-zero change to establish scale
|
|
803
|
+
min_change = min(changes)
|
|
804
|
+
avg_change = sum(changes) / len(changes)
|
|
805
|
+
|
|
806
|
+
for tick in candidate_ticks:
|
|
807
|
+
# Skip ticks that are too small (less than 1/10 of smallest change)
|
|
808
|
+
if tick < min_change * 0.1:
|
|
809
|
+
continue
|
|
810
|
+
|
|
811
|
+
# Skip ticks that are way too large
|
|
812
|
+
if tick > avg_change * 10:
|
|
813
|
+
continue
|
|
814
|
+
|
|
815
|
+
# Round all changes to this tick size
|
|
816
|
+
rounded = [round(c / tick) * tick for c in changes]
|
|
817
|
+
|
|
818
|
+
# Calculate how well the rounding fits
|
|
819
|
+
errors = [abs(c - r) for c, r in zip(changes, rounded)]
|
|
820
|
+
max_error = max(errors)
|
|
821
|
+
|
|
822
|
+
# Key insight: if max error is less than tick/2, this tick captures the grid well
|
|
823
|
+
if max_error < tick * 0.5:
|
|
824
|
+
# Count how many changes are multiples of this tick (within tolerance)
|
|
825
|
+
tolerance = tick * 0.1
|
|
826
|
+
multiples = sum(1 for c in changes if abs(round(c / tick) * tick - c) < tolerance)
|
|
827
|
+
multiple_ratio = multiples / len(changes)
|
|
828
|
+
|
|
829
|
+
# Score based on how many values are clean multiples
|
|
830
|
+
if multiple_ratio > 0.7: # Most values are clean multiples
|
|
831
|
+
score = multiple_ratio
|
|
832
|
+
|
|
833
|
+
# Prefer larger ticks (less precision) when scores are similar
|
|
834
|
+
# This helps choose 0.00001 over 0.000001 when both fit
|
|
835
|
+
score *= (1.0 + tick * 100) # Small bonus for larger ticks
|
|
836
|
+
|
|
837
|
+
if score > best_score:
|
|
838
|
+
best_score = score
|
|
839
|
+
best_tick = tick
|
|
840
|
+
|
|
841
|
+
# If no good tick found with strict criteria, fall back to simple analysis
|
|
842
|
+
if best_tick == 0:
|
|
843
|
+
# Find the most common order of magnitude in changes
|
|
844
|
+
magnitudes = []
|
|
845
|
+
for c in changes:
|
|
846
|
+
if c > 0:
|
|
847
|
+
# Find order of magnitude
|
|
848
|
+
mag = 10 ** math.floor(math.log10(c))
|
|
849
|
+
magnitudes.append(mag)
|
|
850
|
+
|
|
851
|
+
if magnitudes:
|
|
852
|
+
# Most common magnitude
|
|
853
|
+
counter = Counter(magnitudes)
|
|
854
|
+
common_mag = counter.most_common(1)[0][0]
|
|
855
|
+
# Use tick as 1/10 of common magnitude
|
|
856
|
+
best_tick = common_mag / 10
|
|
857
|
+
best_score = 0.5
|
|
858
|
+
|
|
859
|
+
# Calculate confidence based on score
|
|
860
|
+
if best_score > 0.8:
|
|
861
|
+
confidence = 0.9
|
|
862
|
+
elif best_score > 0.6:
|
|
863
|
+
confidence = 0.7
|
|
864
|
+
else:
|
|
865
|
+
confidence = best_score
|
|
866
|
+
|
|
867
|
+
return best_tick, confidence
|
|
868
|
+
|
|
869
|
+
def _calculate_decimal_tick(self) -> tuple[float, float]:
|
|
870
|
+
"""
|
|
871
|
+
Calculate tick size based on decimal places.
|
|
872
|
+
Returns (tick_size, confidence)
|
|
873
|
+
"""
|
|
874
|
+
if not self._price_decimals:
|
|
875
|
+
# No decimals found, probably integer prices
|
|
876
|
+
return 1.0, 0.5
|
|
877
|
+
|
|
878
|
+
# Filter out noise from float representation
|
|
879
|
+
# If we have 15 decimals, it's likely float noise
|
|
880
|
+
valid_decimals = [d for d in self._price_decimals if d <= 10]
|
|
881
|
+
|
|
882
|
+
if not valid_decimals:
|
|
883
|
+
# All decimals are noise, assume 2 decimal places (cents)
|
|
884
|
+
return 0.01, 0.3
|
|
885
|
+
|
|
886
|
+
# Use most common valid decimal places
|
|
887
|
+
max_decimals = max(valid_decimals)
|
|
888
|
+
tick_size = 10 ** (-max_decimals)
|
|
889
|
+
|
|
890
|
+
# Lower confidence for decimal-only method
|
|
891
|
+
return tick_size, 0.5
|
|
892
|
+
|
|
893
|
+
@staticmethod
|
|
894
|
+
def _combine_tick_estimates(freq: tuple[float, float],
|
|
895
|
+
decimal: tuple[float, float]) -> tuple[float, float]:
|
|
896
|
+
"""
|
|
897
|
+
Combine tick size estimates from frequency and decimal methods only.
|
|
898
|
+
Returns (tick_size, confidence)
|
|
899
|
+
"""
|
|
900
|
+
estimates = []
|
|
901
|
+
|
|
902
|
+
if freq[0] > 0 and freq[1] > 0:
|
|
903
|
+
estimates.append(freq)
|
|
904
|
+
if decimal[0] > 0 and decimal[1] > 0:
|
|
905
|
+
estimates.append(decimal)
|
|
906
|
+
|
|
907
|
+
if not estimates:
|
|
908
|
+
return 0.01, 0.1 # Default fallback
|
|
909
|
+
|
|
910
|
+
# Use highest confidence estimate
|
|
911
|
+
best = max(estimates, key=lambda x: x[1])
|
|
912
|
+
return best
|
|
913
|
+
|
|
914
|
+
def _collect_trading_hours(self, candle: OHLCV) -> None:
|
|
915
|
+
"""
|
|
916
|
+
Collect trading hours data from timestamps.
|
|
917
|
+
Only collect for candles with actual volume (not gaps).
|
|
918
|
+
"""
|
|
919
|
+
if candle.volume <= 0:
|
|
920
|
+
return # Skip gaps
|
|
921
|
+
|
|
922
|
+
# Convert timestamp to datetime
|
|
923
|
+
dt = datetime.fromtimestamp(candle.timestamp, tz=None) # Local time
|
|
924
|
+
|
|
925
|
+
# Get weekday (1=Monday, 7=Sunday) and hour
|
|
926
|
+
weekday = dt.isoweekday()
|
|
927
|
+
hour = dt.hour
|
|
928
|
+
|
|
929
|
+
# Count occurrences
|
|
930
|
+
key = (weekday, hour)
|
|
931
|
+
self._trading_hours[key] = self._trading_hours.get(key, 0) + 1
|
|
932
|
+
|
|
933
|
+
def _collect_existing_trading_hours(self) -> None:
|
|
934
|
+
"""
|
|
935
|
+
Collect trading hours data from existing file for opening hours analysis.
|
|
936
|
+
Only samples a subset of data for performance reasons.
|
|
937
|
+
"""
|
|
938
|
+
if not self._file or self._size == 0:
|
|
939
|
+
return
|
|
940
|
+
|
|
941
|
+
# Save current position
|
|
942
|
+
current_pos = self._file.tell()
|
|
943
|
+
|
|
944
|
+
try:
|
|
945
|
+
# Sample data: read every Nth record for performance
|
|
946
|
+
# For large files, we don't need to read everything
|
|
947
|
+
sample_interval = max(1, self._size // 1000) # Sample up to 1000 points
|
|
948
|
+
|
|
949
|
+
for i in range(0, self._size, sample_interval):
|
|
950
|
+
self._file.seek(i * RECORD_SIZE)
|
|
951
|
+
data = self._file.read(RECORD_SIZE)
|
|
952
|
+
|
|
953
|
+
if len(data) == RECORD_SIZE:
|
|
954
|
+
# Unpack the record
|
|
955
|
+
timestamp, open_val, high, low, close, volume = \
|
|
956
|
+
struct.unpack('Ifffff', data)
|
|
957
|
+
|
|
958
|
+
# Only collect if volume > 0 (real trading)
|
|
959
|
+
if volume > 0:
|
|
960
|
+
dt = datetime.fromtimestamp(timestamp, tz=None)
|
|
961
|
+
weekday = dt.isoweekday()
|
|
962
|
+
hour = dt.hour
|
|
963
|
+
key = (weekday, hour)
|
|
964
|
+
self._trading_hours[key] = self._trading_hours.get(key, 0) + 1
|
|
965
|
+
|
|
966
|
+
finally:
|
|
967
|
+
# Restore file position
|
|
968
|
+
self._file.seek(current_pos)
|
|
969
|
+
|
|
970
|
+
def _has_enough_data_for_opening_hours(self) -> bool:
|
|
971
|
+
"""
|
|
972
|
+
Check if we have enough data to analyze opening hours based on timeframe.
|
|
973
|
+
"""
|
|
974
|
+
if not self._trading_hours or not self._interval:
|
|
975
|
+
return False
|
|
976
|
+
|
|
977
|
+
# For daily or larger timeframes
|
|
978
|
+
if self._interval >= 86400: # >= 1 day
|
|
979
|
+
# We need at least a few days to see a pattern
|
|
980
|
+
unique_days = len(set(day for day, hour in self._trading_hours.keys()))
|
|
981
|
+
return unique_days >= 3 # At least 3 different days
|
|
982
|
+
|
|
983
|
+
# For intraday timeframes
|
|
984
|
+
# Check if we have at least some meaningful data
|
|
985
|
+
# We need enough to see a pattern
|
|
986
|
+
data_points = sum(self._trading_hours.values())
|
|
987
|
+
points_per_hour = 3600 / self._interval
|
|
988
|
+
hours_covered = data_points / points_per_hour
|
|
989
|
+
|
|
990
|
+
# Need at least 2 hours of data to detect any pattern
|
|
991
|
+
# This allows even short sessions to be analyzed
|
|
992
|
+
return hours_covered >= 2
|
|
993
|
+
|
|
994
|
+
def _analyze_opening_hours(self) -> None:
|
|
995
|
+
"""
|
|
996
|
+
Analyze collected trading hours to determine opening hours pattern.
|
|
997
|
+
Works for both intraday and daily timeframes.
|
|
998
|
+
"""
|
|
999
|
+
if not self._trading_hours:
|
|
1000
|
+
self._analyzed_opening_hours = None
|
|
1001
|
+
return
|
|
1002
|
+
|
|
1003
|
+
# For daily or larger timeframes, analyze which days have trading
|
|
1004
|
+
if self._interval and self._interval >= 86400: # >= 1 day
|
|
1005
|
+
hours: list = []
|
|
1006
|
+
days_with_trading = set(day for day, hour in self._trading_hours.keys())
|
|
1007
|
+
|
|
1008
|
+
# Check if it's 24/7 (all 7 days have trading)
|
|
1009
|
+
if len(days_with_trading) == 7:
|
|
1010
|
+
# 24/7 trading pattern
|
|
1011
|
+
for day in range(1, 8):
|
|
1012
|
+
hours.append(SymInfoInterval(
|
|
1013
|
+
day=day,
|
|
1014
|
+
start=time(0, 0, 0),
|
|
1015
|
+
end=time(23, 59, 59)
|
|
1016
|
+
))
|
|
1017
|
+
elif days_with_trading <= {1, 2, 3, 4, 5}: # Monday-Friday only
|
|
1018
|
+
# Business days pattern (stock/forex)
|
|
1019
|
+
for day in range(1, 6):
|
|
1020
|
+
hours.append(SymInfoInterval(
|
|
1021
|
+
day=day,
|
|
1022
|
+
start=time(9, 30, 0), # Default to US market hours
|
|
1023
|
+
end=time(16, 0, 0)
|
|
1024
|
+
))
|
|
1025
|
+
else:
|
|
1026
|
+
# Mixed pattern - include all days that have trading
|
|
1027
|
+
for day in sorted(days_with_trading):
|
|
1028
|
+
hours.append(SymInfoInterval(
|
|
1029
|
+
day=day,
|
|
1030
|
+
start=time(0, 0, 0), # Default to full day for daily data
|
|
1031
|
+
end=time(23, 59, 59)
|
|
1032
|
+
))
|
|
1033
|
+
self._analyzed_opening_hours = hours
|
|
1034
|
+
return
|
|
1035
|
+
|
|
1036
|
+
# For intraday data, analyze hourly patterns
|
|
1037
|
+
# Check if it's 24/7 trading (crypto pattern)
|
|
1038
|
+
total_hours = len(self._trading_hours)
|
|
1039
|
+
if total_hours >= 168 * 0.7: # 70% of all hours in a week (lowered threshold)
|
|
1040
|
+
# Check if all hours have similar activity
|
|
1041
|
+
counts = list(self._trading_hours.values())
|
|
1042
|
+
avg_count = sum(counts) / len(counts)
|
|
1043
|
+
variance = sum((c - avg_count) ** 2 for c in counts) / len(counts)
|
|
1044
|
+
|
|
1045
|
+
# If low variance, it's likely 24/7
|
|
1046
|
+
if variance < avg_count * 0.5:
|
|
1047
|
+
hours = []
|
|
1048
|
+
for day in range(1, 8):
|
|
1049
|
+
hours.append(SymInfoInterval(
|
|
1050
|
+
day=day,
|
|
1051
|
+
start=time(0, 0, 0),
|
|
1052
|
+
end=time(23, 59, 59)
|
|
1053
|
+
))
|
|
1054
|
+
self._analyzed_opening_hours = hours
|
|
1055
|
+
return
|
|
1056
|
+
|
|
1057
|
+
# Analyze per-day patterns for intraday
|
|
1058
|
+
hours = []
|
|
1059
|
+
|
|
1060
|
+
for day in range(1, 8): # Monday to Sunday
|
|
1061
|
+
# Get all hours for this day
|
|
1062
|
+
day_hours = [(hour, count) for (d, hour), count in self._trading_hours.items() if d == day]
|
|
1063
|
+
|
|
1064
|
+
if not day_hours:
|
|
1065
|
+
continue # No trading on this day
|
|
1066
|
+
|
|
1067
|
+
# Sort by hour
|
|
1068
|
+
day_hours.sort(key=lambda x: x[0])
|
|
1069
|
+
|
|
1070
|
+
# Find continuous trading periods
|
|
1071
|
+
periods = []
|
|
1072
|
+
current_start = None
|
|
1073
|
+
current_end = None
|
|
1074
|
+
|
|
1075
|
+
# Threshold: consider an hour active if it has at least 20% of average activity
|
|
1076
|
+
total_count = sum(count for _, count in day_hours)
|
|
1077
|
+
if total_count == 0:
|
|
1078
|
+
continue
|
|
1079
|
+
avg_hour_count = total_count / len(day_hours)
|
|
1080
|
+
threshold = avg_hour_count * 0.2
|
|
1081
|
+
|
|
1082
|
+
for hour, count in day_hours:
|
|
1083
|
+
if count >= threshold:
|
|
1084
|
+
if current_start is None:
|
|
1085
|
+
current_start = hour
|
|
1086
|
+
current_end = hour
|
|
1087
|
+
else:
|
|
1088
|
+
current_end = hour
|
|
1089
|
+
else:
|
|
1090
|
+
if current_start is not None:
|
|
1091
|
+
periods.append((current_start, current_end))
|
|
1092
|
+
current_start = None
|
|
1093
|
+
current_end = None
|
|
1094
|
+
|
|
1095
|
+
# Add last period if exists
|
|
1096
|
+
if current_start is not None:
|
|
1097
|
+
periods.append((current_start, current_end))
|
|
1098
|
+
|
|
1099
|
+
# Convert periods to SymInfoInterval
|
|
1100
|
+
for start_hour, end_hour in periods:
|
|
1101
|
+
hours.append(SymInfoInterval(
|
|
1102
|
+
day=day,
|
|
1103
|
+
start=time(start_hour, 0, 0),
|
|
1104
|
+
end=time(end_hour, 59, 59)
|
|
1105
|
+
))
|
|
1106
|
+
|
|
1107
|
+
# If no opening hours detected, default to business hours
|
|
1108
|
+
if not hours:
|
|
1109
|
+
for day in range(1, 6): # Monday to Friday
|
|
1110
|
+
hours.append(SymInfoInterval(
|
|
1111
|
+
day=day,
|
|
1112
|
+
start=time(9, 30, 0),
|
|
1113
|
+
end=time(16, 0, 0)
|
|
1114
|
+
))
|
|
1115
|
+
|
|
1116
|
+
self._analyzed_opening_hours = hours
|
|
1117
|
+
|
|
1118
|
+
def _rebuild_with_correct_interval(self, new_interval: int) -> None:
|
|
1119
|
+
"""
|
|
1120
|
+
Rebuild the entire file with the correct interval when a smaller interval is detected.
|
|
1121
|
+
This happens when initial interval was wrong due to gaps.
|
|
1122
|
+
|
|
1123
|
+
:param new_interval: The correct interval to use
|
|
1124
|
+
"""
|
|
1125
|
+
import tempfile
|
|
1126
|
+
import shutil
|
|
1127
|
+
|
|
1128
|
+
if not self._file or self._size == 0:
|
|
1129
|
+
return
|
|
1130
|
+
|
|
1131
|
+
# Save current file position and data
|
|
1132
|
+
current_records = []
|
|
1133
|
+
|
|
1134
|
+
# Read all existing records
|
|
1135
|
+
self._file.seek(0)
|
|
1136
|
+
for i in range(self._size):
|
|
1137
|
+
offset = i * RECORD_SIZE
|
|
1138
|
+
self._file.seek(offset)
|
|
1139
|
+
data = self._file.read(RECORD_SIZE)
|
|
1140
|
+
if len(data) == RECORD_SIZE:
|
|
1141
|
+
record = struct.unpack(STRUCT_FORMAT, data)
|
|
1142
|
+
current_records.append(OHLCV(*record, extra_fields={}))
|
|
1143
|
+
|
|
1144
|
+
# Create temp file for rebuilding
|
|
1145
|
+
temp_fd, temp_path = tempfile.mkstemp(suffix='.ohlcv.tmp', dir=os.path.dirname(self.path))
|
|
1146
|
+
try:
|
|
1147
|
+
# Close temp file descriptor as we'll open it differently
|
|
1148
|
+
os.close(temp_fd)
|
|
1149
|
+
|
|
1150
|
+
# Create new writer with temp file
|
|
1151
|
+
with OHLCVWriter(temp_path) as temp_writer:
|
|
1152
|
+
# Write all records with correct interval
|
|
1153
|
+
# The writer will now properly handle gaps
|
|
1154
|
+
for record in current_records:
|
|
1155
|
+
temp_writer.write(record)
|
|
1156
|
+
|
|
1157
|
+
# Close current file and clean up extra CSV (will be rebuilt)
|
|
1158
|
+
self._file.close()
|
|
1159
|
+
self._close_extra_csv()
|
|
1160
|
+
extra_path = Path(self.path).with_suffix('.extra.csv')
|
|
1161
|
+
if extra_path.exists():
|
|
1162
|
+
extra_path.unlink()
|
|
1163
|
+
self._extra_headers = None
|
|
1164
|
+
self._extra_row_count = 0
|
|
1165
|
+
|
|
1166
|
+
# Replace original with rebuilt file
|
|
1167
|
+
shutil.move(temp_path, self.path)
|
|
1168
|
+
|
|
1169
|
+
# Reopen the file
|
|
1170
|
+
f = open(self.path, 'rb+')
|
|
1171
|
+
self._file = f
|
|
1172
|
+
self._size = os.path.getsize(self.path) // RECORD_SIZE
|
|
1173
|
+
|
|
1174
|
+
# Reset interval to the correct one
|
|
1175
|
+
self._interval = new_interval
|
|
1176
|
+
|
|
1177
|
+
# Position at end for appending
|
|
1178
|
+
f.seek(0, os.SEEK_END)
|
|
1179
|
+
self._current_pos = self._size
|
|
1180
|
+
|
|
1181
|
+
# Update last timestamp
|
|
1182
|
+
if self._size > 0:
|
|
1183
|
+
f.seek((self._size - 1) * RECORD_SIZE)
|
|
1184
|
+
data = f.read(4)
|
|
1185
|
+
self._last_timestamp = struct.unpack('I', data)[0]
|
|
1186
|
+
f.seek(0, os.SEEK_END)
|
|
1187
|
+
|
|
1188
|
+
except Exception as e:
|
|
1189
|
+
# Clean up temp file on error
|
|
1190
|
+
if os.path.exists(temp_path):
|
|
1191
|
+
try:
|
|
1192
|
+
os.unlink(temp_path)
|
|
1193
|
+
except OSError:
|
|
1194
|
+
pass
|
|
1195
|
+
raise IOError(f"Failed to rebuild file with correct interval: {e}")
|
|
1196
|
+
|
|
1197
|
+
def _parse_and_write_ohlcv_row(self, ts_str: str, row: list[str],
|
|
1198
|
+
o_idx: int, h_idx: int, l_idx: int, c_idx: int, v_idx: int,
|
|
1199
|
+
timestamp_format: str | None,
|
|
1200
|
+
timezone: dt_timezone | ZoneInfo | None) -> None:
|
|
1201
|
+
"""
|
|
1202
|
+
Parse timestamp and write OHLCV row with error handling.
|
|
1203
|
+
|
|
1204
|
+
:param ts_str: Timestamp string to parse
|
|
1205
|
+
:param row: Data row containing OHLCV values
|
|
1206
|
+
:param o_idx: Index of open price
|
|
1207
|
+
:param h_idx: Index of high price
|
|
1208
|
+
:param l_idx: Index of low price
|
|
1209
|
+
:param c_idx: Index of close price
|
|
1210
|
+
:param v_idx: Index of volume
|
|
1211
|
+
:param timestamp_format: Optional timestamp format
|
|
1212
|
+
:param timezone: Timezone object
|
|
1213
|
+
:raises ValueError: If parsing or data conversion fails
|
|
1214
|
+
"""
|
|
1215
|
+
# Parse timestamp
|
|
1216
|
+
try:
|
|
1217
|
+
timestamp = _parse_timestamp(ts_str, timestamp_format, timezone)
|
|
1218
|
+
except Exception as e:
|
|
1219
|
+
raise ValueError(f"Failed to parse timestamp '{ts_str}': {e}")
|
|
1220
|
+
|
|
1221
|
+
# Write OHLCV data
|
|
1222
|
+
try:
|
|
1223
|
+
self.write(OHLCV(
|
|
1224
|
+
timestamp,
|
|
1225
|
+
float(row[o_idx]),
|
|
1226
|
+
float(row[h_idx]),
|
|
1227
|
+
float(row[l_idx]),
|
|
1228
|
+
float(row[c_idx]),
|
|
1229
|
+
float(row[v_idx])
|
|
1230
|
+
))
|
|
1231
|
+
except (ValueError, IndexError) as e:
|
|
1232
|
+
raise ValueError(f"Invalid data in row: {e}")
|
|
1233
|
+
|
|
1234
|
+
def load_from_csv(self, path: str | Path,
|
|
1235
|
+
timestamp_format: str | None = None,
|
|
1236
|
+
timestamp_column: str | None = None,
|
|
1237
|
+
date_column: str | None = None,
|
|
1238
|
+
time_column: str | None = None,
|
|
1239
|
+
tz: str | None = None) -> None:
|
|
1240
|
+
"""
|
|
1241
|
+
Load OHLCV data from CSV file using only builtin modules.
|
|
1242
|
+
|
|
1243
|
+
:param path: Path to CSV file
|
|
1244
|
+
:param timestamp_format: Optional datetime fmt for parsing
|
|
1245
|
+
:param timestamp_column: Column name for timestamp (default tries: timestamp, time, date)
|
|
1246
|
+
:param date_column: When timestamp is split into date+time columns, date column name
|
|
1247
|
+
:param time_column: When timestamp is split into date+time columns, time column name
|
|
1248
|
+
:param tz: Timezone name (e.g. 'UTC', 'Europe/London', '+0100') for timestamp conversion
|
|
1249
|
+
"""
|
|
1250
|
+
timezone = _parse_timezone_param(tz)
|
|
1251
|
+
|
|
1252
|
+
# Read CSV headers first
|
|
1253
|
+
with open(path, 'r') as f:
|
|
1254
|
+
reader = csv.reader(f)
|
|
1255
|
+
headers = [h.lower() for h in next(reader)] # Case insensitive
|
|
1256
|
+
|
|
1257
|
+
# Find timestamp and OHLCV columns
|
|
1258
|
+
timestamp_idx, date_idx, time_idx = _find_timestamp_columns(headers, timestamp_column, date_column,
|
|
1259
|
+
time_column)
|
|
1260
|
+
o_idx, h_idx, l_idx, c_idx, v_idx = _find_ohlcv_columns(headers)
|
|
1261
|
+
|
|
1262
|
+
# Process data rows
|
|
1263
|
+
for row in reader:
|
|
1264
|
+
# Handle timestamp
|
|
1265
|
+
if date_idx is not None and time_idx is not None:
|
|
1266
|
+
# Combine date and time
|
|
1267
|
+
ts_str = f"{row[date_idx]} {row[time_idx]}"
|
|
1268
|
+
else:
|
|
1269
|
+
assert timestamp_idx is not None
|
|
1270
|
+
ts_str = row[timestamp_idx]
|
|
1271
|
+
|
|
1272
|
+
# Parse and write row
|
|
1273
|
+
self._parse_and_write_ohlcv_row(ts_str, row, o_idx, h_idx, l_idx, c_idx, v_idx,
|
|
1274
|
+
timestamp_format, timezone)
|
|
1275
|
+
|
|
1276
|
+
def load_from_txt(self, path: str | Path,
|
|
1277
|
+
timestamp_format: str | None = None,
|
|
1278
|
+
timestamp_column: str | None = None,
|
|
1279
|
+
date_column: str | None = None,
|
|
1280
|
+
time_column: str | None = None,
|
|
1281
|
+
tz: str | None = None) -> None:
|
|
1282
|
+
"""
|
|
1283
|
+
Load OHLCV data from TXT file using only builtin modules.
|
|
1284
|
+
|
|
1285
|
+
:param path: Path to TXT file
|
|
1286
|
+
:param timestamp_format: Optional datetime fmt for parsing
|
|
1287
|
+
:param timestamp_column: Column name for timestamp (default tries: timestamp, time, date)
|
|
1288
|
+
:param date_column: When timestamp is split into date+time columns, date column name
|
|
1289
|
+
:param time_column: When timestamp is split into date+time columns, time column name
|
|
1290
|
+
:param tz: Timezone name (e.g. 'UTC', 'Europe/London', '+0100') for timestamp conversion
|
|
1291
|
+
"""
|
|
1292
|
+
timezone = _parse_timezone_param(tz)
|
|
1293
|
+
|
|
1294
|
+
# Auto-detect delimiter
|
|
1295
|
+
with open(path, 'r') as f:
|
|
1296
|
+
first_line = f.readline().strip()
|
|
1297
|
+
if not first_line:
|
|
1298
|
+
raise ValueError("File is empty or first line is blank")
|
|
1299
|
+
|
|
1300
|
+
# Check for common delimiters in order of preference
|
|
1301
|
+
delimiters = ['\t', ';', '|']
|
|
1302
|
+
delimiter_counts = {}
|
|
1303
|
+
|
|
1304
|
+
for delim in delimiters:
|
|
1305
|
+
count = first_line.count(delim)
|
|
1306
|
+
if count > 0:
|
|
1307
|
+
delimiter_counts[delim] = count
|
|
1308
|
+
|
|
1309
|
+
if not delimiter_counts:
|
|
1310
|
+
raise ValueError("No supported delimiter found (tab, semicolon, or pipe)")
|
|
1311
|
+
|
|
1312
|
+
# Use delimiter with highest count
|
|
1313
|
+
delimiter = max(delimiter_counts, key=lambda x: delimiter_counts[x])
|
|
1314
|
+
|
|
1315
|
+
# Read TXT file with manual parsing for better control
|
|
1316
|
+
with open(path, 'r') as f:
|
|
1317
|
+
lines = f.readlines()
|
|
1318
|
+
|
|
1319
|
+
if not lines:
|
|
1320
|
+
raise ValueError("File is empty")
|
|
1321
|
+
|
|
1322
|
+
# Parse header line
|
|
1323
|
+
header_line = lines[0].strip()
|
|
1324
|
+
if not header_line:
|
|
1325
|
+
raise ValueError("Header row is empty")
|
|
1326
|
+
|
|
1327
|
+
headers = self._parse_txt_line(header_line, delimiter)
|
|
1328
|
+
headers = [h.lower().strip() for h in headers] # Case insensitive
|
|
1329
|
+
|
|
1330
|
+
if not headers:
|
|
1331
|
+
raise ValueError("No headers found")
|
|
1332
|
+
|
|
1333
|
+
# Find timestamp and OHLCV columns
|
|
1334
|
+
timestamp_idx, date_idx, time_idx = _find_timestamp_columns(headers, timestamp_column, date_column, time_column)
|
|
1335
|
+
o_idx, h_idx, l_idx, c_idx, v_idx = _find_ohlcv_columns(headers)
|
|
1336
|
+
|
|
1337
|
+
# Process data rows
|
|
1338
|
+
for line in lines[1:]: # Skip header
|
|
1339
|
+
line = line.strip()
|
|
1340
|
+
if not line: # Skip empty lines
|
|
1341
|
+
continue
|
|
1342
|
+
|
|
1343
|
+
row = self._parse_txt_line(line, delimiter)
|
|
1344
|
+
|
|
1345
|
+
if len(row) != len(headers):
|
|
1346
|
+
raise ValueError(f"Row has {len(row)} columns, expected {len(headers)}")
|
|
1347
|
+
|
|
1348
|
+
# Strip whitespace from all fields
|
|
1349
|
+
row = [field.strip() for field in row]
|
|
1350
|
+
|
|
1351
|
+
# Handle timestamp
|
|
1352
|
+
if date_idx is not None and time_idx is not None:
|
|
1353
|
+
# Combine date and time
|
|
1354
|
+
ts_str = f"{row[date_idx]} {row[time_idx]}"
|
|
1355
|
+
else:
|
|
1356
|
+
ts_str = str(row[timestamp_idx]) if timestamp_idx is not None and timestamp_idx < len(row) else ""
|
|
1357
|
+
|
|
1358
|
+
# Parse and write row
|
|
1359
|
+
self._parse_and_write_ohlcv_row(ts_str, row, o_idx, h_idx, l_idx, c_idx, v_idx,
|
|
1360
|
+
timestamp_format, timezone)
|
|
1361
|
+
|
|
1362
|
+
@staticmethod
|
|
1363
|
+
def _parse_txt_line(line: str, delimiter: str) -> list[str]:
|
|
1364
|
+
"""
|
|
1365
|
+
Parse a single TXT line with proper handling of quoted fields and escape characters.
|
|
1366
|
+
|
|
1367
|
+
:param line: Line to parse
|
|
1368
|
+
:param delimiter: Delimiter character
|
|
1369
|
+
:return: List of parsed fields
|
|
1370
|
+
:raises ValueError: If line format is invalid
|
|
1371
|
+
"""
|
|
1372
|
+
if not line:
|
|
1373
|
+
return []
|
|
1374
|
+
|
|
1375
|
+
fields = []
|
|
1376
|
+
current_field = ""
|
|
1377
|
+
in_quotes = False
|
|
1378
|
+
quote_char = None
|
|
1379
|
+
i = 0
|
|
1380
|
+
|
|
1381
|
+
while i < len(line):
|
|
1382
|
+
char = line[i]
|
|
1383
|
+
|
|
1384
|
+
# Handle escape characters
|
|
1385
|
+
if char == '\\' and i + 1 < len(line):
|
|
1386
|
+
next_char = line[i + 1]
|
|
1387
|
+
if next_char in ['"', "'", '\\', 'n', 't', 'r']:
|
|
1388
|
+
if next_char == 'n':
|
|
1389
|
+
current_field += '\n'
|
|
1390
|
+
elif next_char == 't':
|
|
1391
|
+
current_field += '\t'
|
|
1392
|
+
elif next_char == 'r':
|
|
1393
|
+
current_field += '\r'
|
|
1394
|
+
else:
|
|
1395
|
+
current_field += next_char
|
|
1396
|
+
i += 2
|
|
1397
|
+
continue
|
|
1398
|
+
else:
|
|
1399
|
+
current_field += char
|
|
1400
|
+
i += 1
|
|
1401
|
+
continue
|
|
1402
|
+
|
|
1403
|
+
# Handle quotes
|
|
1404
|
+
if char in ['"', "'"] and not in_quotes:
|
|
1405
|
+
in_quotes = True
|
|
1406
|
+
quote_char = char
|
|
1407
|
+
i += 1
|
|
1408
|
+
continue
|
|
1409
|
+
elif char == quote_char and in_quotes:
|
|
1410
|
+
# Check for escaped quote (double quote)
|
|
1411
|
+
if i + 1 < len(line) and line[i + 1] == quote_char:
|
|
1412
|
+
current_field += char
|
|
1413
|
+
i += 2
|
|
1414
|
+
continue
|
|
1415
|
+
else:
|
|
1416
|
+
in_quotes = False
|
|
1417
|
+
quote_char = None
|
|
1418
|
+
i += 1
|
|
1419
|
+
continue
|
|
1420
|
+
|
|
1421
|
+
# Handle delimiter
|
|
1422
|
+
if char == delimiter and not in_quotes:
|
|
1423
|
+
fields.append(current_field)
|
|
1424
|
+
current_field = ""
|
|
1425
|
+
i += 1
|
|
1426
|
+
continue
|
|
1427
|
+
|
|
1428
|
+
# Regular character
|
|
1429
|
+
current_field += char
|
|
1430
|
+
i += 1
|
|
1431
|
+
|
|
1432
|
+
# Add the last field
|
|
1433
|
+
fields.append(current_field)
|
|
1434
|
+
|
|
1435
|
+
# Validate that quotes are properly closed
|
|
1436
|
+
if in_quotes:
|
|
1437
|
+
raise ValueError(f"Unclosed quote in line: {line[:50]}...")
|
|
1438
|
+
|
|
1439
|
+
return fields
|
|
1440
|
+
|
|
1441
|
+
def load_from_json(self, path: str | Path,
|
|
1442
|
+
timestamp_format: str | None = None,
|
|
1443
|
+
timestamp_field: str | None = None,
|
|
1444
|
+
date_field: str | None = None,
|
|
1445
|
+
time_field: str | None = None,
|
|
1446
|
+
tz: str | None = None,
|
|
1447
|
+
mapping: dict[str, str] | None = None) -> None:
|
|
1448
|
+
"""
|
|
1449
|
+
Load OHLCV data from JSON file using only builtin modules.
|
|
1450
|
+
|
|
1451
|
+
:param path: Path to JSON file
|
|
1452
|
+
:param timestamp_format: Optional datetime format for parsing
|
|
1453
|
+
:param timestamp_field: Field name for timestamp (default tries: timestamp, time, date, t)
|
|
1454
|
+
:param date_field: When timestamp is split, date field name
|
|
1455
|
+
:param time_field: When timestamp is split, time field name
|
|
1456
|
+
:param tz: Timezone name (e.g. 'UTC', 'Europe/London', '+0100')
|
|
1457
|
+
:param mapping: Optional field mapping, e.g. {'timestamp': 't', 'volume': 'vol'}
|
|
1458
|
+
"""
|
|
1459
|
+
timezone = _parse_timezone_param(tz)
|
|
1460
|
+
|
|
1461
|
+
# Setup field mapping
|
|
1462
|
+
field_mapping: dict[str, str] = mapping or {}
|
|
1463
|
+
field_map = {
|
|
1464
|
+
'timestamp': field_mapping.get('timestamp', timestamp_field),
|
|
1465
|
+
'open': field_mapping.get('open', 'open'),
|
|
1466
|
+
'high': field_mapping.get('high', 'high'),
|
|
1467
|
+
'low': field_mapping.get('low', 'low'),
|
|
1468
|
+
'close': field_mapping.get('close', 'close'),
|
|
1469
|
+
'volume': field_mapping.get('volume', 'volume')
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
# Load JSON file
|
|
1473
|
+
data = None
|
|
1474
|
+
with open(path, 'r') as f:
|
|
1475
|
+
data = json.load(f)
|
|
1476
|
+
|
|
1477
|
+
# Ensure we have a list of records
|
|
1478
|
+
if isinstance(data, dict):
|
|
1479
|
+
# Some APIs wrap the data in an object
|
|
1480
|
+
for key in ['data', 'candles', 'ohlcv', 'results']:
|
|
1481
|
+
if key in data and isinstance(data[key], list):
|
|
1482
|
+
data = data[key]
|
|
1483
|
+
break
|
|
1484
|
+
else:
|
|
1485
|
+
raise ValueError("Could not find OHLCV data array in JSON")
|
|
1486
|
+
|
|
1487
|
+
if not isinstance(data, list):
|
|
1488
|
+
raise ValueError("JSON must contain an array of OHLCV records")
|
|
1489
|
+
|
|
1490
|
+
# Find timestamp field if not specified
|
|
1491
|
+
if not field_map['timestamp'] and not (date_field and time_field):
|
|
1492
|
+
common_names = ['timestamp', 'time', 'date', 't']
|
|
1493
|
+
for record in data[:1]: # Check just first record
|
|
1494
|
+
for name in common_names:
|
|
1495
|
+
if name in record:
|
|
1496
|
+
field_map['timestamp'] = name
|
|
1497
|
+
break
|
|
1498
|
+
if field_map['timestamp']:
|
|
1499
|
+
break
|
|
1500
|
+
if not field_map['timestamp']:
|
|
1501
|
+
raise ValueError("Could not find timestamp field")
|
|
1502
|
+
|
|
1503
|
+
# Process records
|
|
1504
|
+
for record in data:
|
|
1505
|
+
# Get timestamp
|
|
1506
|
+
try:
|
|
1507
|
+
if date_field and time_field:
|
|
1508
|
+
# Combine date and time
|
|
1509
|
+
ts_str = f"{record[date_field]} {record[time_field]}"
|
|
1510
|
+
else:
|
|
1511
|
+
ts_str = str(record[field_map['timestamp']])
|
|
1512
|
+
|
|
1513
|
+
# Convert timestamp
|
|
1514
|
+
timestamp = _parse_timestamp(ts_str, timestamp_format, timezone)
|
|
1515
|
+
|
|
1516
|
+
# Get OHLCV values
|
|
1517
|
+
try:
|
|
1518
|
+
self.write(OHLCV(
|
|
1519
|
+
timestamp,
|
|
1520
|
+
float(record[field_map['open']]),
|
|
1521
|
+
float(record[field_map['high']]),
|
|
1522
|
+
float(record[field_map['low']]),
|
|
1523
|
+
float(record[field_map['close']]),
|
|
1524
|
+
float(record[field_map['volume']])
|
|
1525
|
+
))
|
|
1526
|
+
except KeyError as e:
|
|
1527
|
+
raise ValueError(f"Missing field in record: {e}")
|
|
1528
|
+
except ValueError as e:
|
|
1529
|
+
raise ValueError(f"Invalid value in record: {e}")
|
|
1530
|
+
|
|
1531
|
+
except Exception as e:
|
|
1532
|
+
raise ValueError(f"Failed to process record: {e}")
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
class OHLCVReader:
|
|
1536
|
+
"""
|
|
1537
|
+
Very fast OHLCV data reader using memory mapping.
|
|
1538
|
+
"""
|
|
1539
|
+
|
|
1540
|
+
__slots__ = ('path', '_file', '_mmap', '_size', '_start_timestamp', '_interval',
|
|
1541
|
+
'_extra_data', '_extra_headers')
|
|
1542
|
+
|
|
1543
|
+
def __init__(self, path: str | Path):
|
|
1544
|
+
self.path = str(path)
|
|
1545
|
+
self._file = None
|
|
1546
|
+
self._mmap = None
|
|
1547
|
+
self._size = 0
|
|
1548
|
+
self._start_timestamp = None
|
|
1549
|
+
self._interval = None
|
|
1550
|
+
self._extra_data: list[dict[str, int | float | str]] | None = None
|
|
1551
|
+
self._extra_headers: list[str] | None = None
|
|
1552
|
+
|
|
1553
|
+
def __enter__(self):
|
|
1554
|
+
self.open()
|
|
1555
|
+
return self
|
|
1556
|
+
|
|
1557
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
1558
|
+
self.close()
|
|
1559
|
+
|
|
1560
|
+
@property
|
|
1561
|
+
def size(self) -> int:
|
|
1562
|
+
"""
|
|
1563
|
+
Number of records in the file
|
|
1564
|
+
"""
|
|
1565
|
+
return self._size
|
|
1566
|
+
|
|
1567
|
+
@property
|
|
1568
|
+
def start_timestamp(self) -> int | None:
|
|
1569
|
+
"""
|
|
1570
|
+
Timestamp of the first record
|
|
1571
|
+
"""
|
|
1572
|
+
return self._start_timestamp
|
|
1573
|
+
|
|
1574
|
+
@property
|
|
1575
|
+
def start_datetime(self) -> datetime:
|
|
1576
|
+
"""
|
|
1577
|
+
Datetime of the first record
|
|
1578
|
+
"""
|
|
1579
|
+
assert self._start_timestamp is not None
|
|
1580
|
+
return datetime.fromtimestamp(self._start_timestamp, UTC)
|
|
1581
|
+
|
|
1582
|
+
@property
|
|
1583
|
+
def end_timestamp(self) -> int | None:
|
|
1584
|
+
"""
|
|
1585
|
+
Timestamp of the last record
|
|
1586
|
+
"""
|
|
1587
|
+
if self._size == 0:
|
|
1588
|
+
return None
|
|
1589
|
+
|
|
1590
|
+
# Read the actual timestamp from the last record instead of calculating it
|
|
1591
|
+
# This is necessary because gap filling may create non-uniform intervals
|
|
1592
|
+
if self._mmap and self._size > 0:
|
|
1593
|
+
last_record_offset = (self._size - 1) * RECORD_SIZE
|
|
1594
|
+
return struct.unpack('I', self._mmap[last_record_offset:last_record_offset + 4])[0]
|
|
1595
|
+
|
|
1596
|
+
return None
|
|
1597
|
+
|
|
1598
|
+
@property
|
|
1599
|
+
def end_datetime(self) -> datetime:
|
|
1600
|
+
"""
|
|
1601
|
+
Datetime of the last record
|
|
1602
|
+
"""
|
|
1603
|
+
ts = self.end_timestamp
|
|
1604
|
+
assert ts is not None
|
|
1605
|
+
return datetime.fromtimestamp(ts, UTC)
|
|
1606
|
+
|
|
1607
|
+
@property
|
|
1608
|
+
def interval(self) -> int | None:
|
|
1609
|
+
"""
|
|
1610
|
+
Interval between records
|
|
1611
|
+
"""
|
|
1612
|
+
return self._interval
|
|
1613
|
+
|
|
1614
|
+
def open(self) -> 'OHLCVReader':
|
|
1615
|
+
"""
|
|
1616
|
+
Open file and create memory mapping
|
|
1617
|
+
"""
|
|
1618
|
+
self._file = open(self.path, 'rb')
|
|
1619
|
+
if os.path.getsize(self.path) > 0:
|
|
1620
|
+
# Detect if this is a text file masquerading as binary OHLCV
|
|
1621
|
+
self._file.seek(0)
|
|
1622
|
+
first_chunk = self._file.read(32)
|
|
1623
|
+
self._file.seek(0) # Reset position
|
|
1624
|
+
|
|
1625
|
+
try:
|
|
1626
|
+
# If 256 bytes decode as ASCII, it's definitely not binary OHLCV
|
|
1627
|
+
first_chunk.decode('ascii')
|
|
1628
|
+
|
|
1629
|
+
# If we get here, it's text - show error with CLI fix
|
|
1630
|
+
raise ValueError(
|
|
1631
|
+
f"Text file detected with .ohlcv extension!\n"
|
|
1632
|
+
f"To convert CSV to binary OHLCV format:\n"
|
|
1633
|
+
f" pyne data convert-from {Path(self.path).with_suffix('.csv')} "
|
|
1634
|
+
f"--symbol YOUR_SYMBOL --provider custom"
|
|
1635
|
+
)
|
|
1636
|
+
except UnicodeDecodeError:
|
|
1637
|
+
# Can't decode as ASCII → it's binary, proceed normally
|
|
1638
|
+
pass
|
|
1639
|
+
|
|
1640
|
+
self._mmap = mmap.mmap(self._file.fileno(), 0, access=mmap.ACCESS_READ)
|
|
1641
|
+
self._size = os.path.getsize(self.path) // RECORD_SIZE
|
|
1642
|
+
|
|
1643
|
+
if self._size >= 2:
|
|
1644
|
+
self._start_timestamp = struct.unpack('I', self._mmap[0:4])[0]
|
|
1645
|
+
second_timestamp = struct.unpack('I', self._mmap[RECORD_SIZE:RECORD_SIZE + 4])[0]
|
|
1646
|
+
self._interval = second_timestamp - self._start_timestamp
|
|
1647
|
+
|
|
1648
|
+
self._load_extra_csv()
|
|
1649
|
+
|
|
1650
|
+
return self
|
|
1651
|
+
|
|
1652
|
+
def _load_extra_csv(self) -> None:
|
|
1653
|
+
"""
|
|
1654
|
+
Load extra fields from sidecar .extra.csv file if it exists.
|
|
1655
|
+
The sidecar is position-aligned with the binary OHLCV file.
|
|
1656
|
+
"""
|
|
1657
|
+
extra_path = Path(self.path).with_suffix('.extra.csv')
|
|
1658
|
+
if not extra_path.exists():
|
|
1659
|
+
return
|
|
1660
|
+
|
|
1661
|
+
with open(extra_path, 'r', newline='') as f:
|
|
1662
|
+
reader = csv.reader(f)
|
|
1663
|
+
headers = next(reader, None)
|
|
1664
|
+
if not headers:
|
|
1665
|
+
return
|
|
1666
|
+
|
|
1667
|
+
self._extra_headers = headers
|
|
1668
|
+
|
|
1669
|
+
# Detect column types from first non-empty data row
|
|
1670
|
+
rows_raw: list[list[str]] = []
|
|
1671
|
+
col_is_numeric: list[bool | None] = [None] * len(headers)
|
|
1672
|
+
|
|
1673
|
+
for row in reader:
|
|
1674
|
+
rows_raw.append(row)
|
|
1675
|
+
for i, val in enumerate(row):
|
|
1676
|
+
if col_is_numeric[i] is None and val and val.lower() not in ('', 'nan', 'na'):
|
|
1677
|
+
try:
|
|
1678
|
+
float(val)
|
|
1679
|
+
col_is_numeric[i] = True
|
|
1680
|
+
except ValueError:
|
|
1681
|
+
col_is_numeric[i] = False
|
|
1682
|
+
|
|
1683
|
+
# Default undetected columns to string
|
|
1684
|
+
col_is_numeric = [v if v is not None else False for v in col_is_numeric]
|
|
1685
|
+
|
|
1686
|
+
# Parse all rows with detected types
|
|
1687
|
+
extra_data: list[dict[str, int | float | str]] = []
|
|
1688
|
+
for row in rows_raw:
|
|
1689
|
+
parsed: dict[str, int | float | str] = {}
|
|
1690
|
+
for i, header in enumerate(headers):
|
|
1691
|
+
val = row[i] if i < len(row) else ''
|
|
1692
|
+
if col_is_numeric[i]:
|
|
1693
|
+
if not val or val.lower() in ('nan', 'na', ''):
|
|
1694
|
+
parsed[header] = float('nan')
|
|
1695
|
+
else:
|
|
1696
|
+
parsed[header] = float(val)
|
|
1697
|
+
else:
|
|
1698
|
+
parsed[header] = val
|
|
1699
|
+
extra_data.append(parsed)
|
|
1700
|
+
self._extra_data = extra_data
|
|
1701
|
+
|
|
1702
|
+
def __iter__(self) -> Iterator[OHLCV]:
|
|
1703
|
+
"""
|
|
1704
|
+
Iterate through all candles
|
|
1705
|
+
"""
|
|
1706
|
+
for pos in range(self._size):
|
|
1707
|
+
yield self.read(pos)
|
|
1708
|
+
|
|
1709
|
+
def read(self, position: int) -> OHLCV:
|
|
1710
|
+
"""
|
|
1711
|
+
Read a single candle at given position
|
|
1712
|
+
"""
|
|
1713
|
+
if position < 0 or position >= self._size:
|
|
1714
|
+
raise IndexError("Position out of range")
|
|
1715
|
+
|
|
1716
|
+
assert self._mmap is not None
|
|
1717
|
+
|
|
1718
|
+
offset = position * RECORD_SIZE
|
|
1719
|
+
data = struct.unpack(STRUCT_FORMAT, self._mmap[offset:offset + RECORD_SIZE])
|
|
1720
|
+
|
|
1721
|
+
extra = {}
|
|
1722
|
+
if self._extra_data is not None and position < len(self._extra_data):
|
|
1723
|
+
extra = self._extra_data[position]
|
|
1724
|
+
|
|
1725
|
+
return OHLCV(*data, extra_fields=extra)
|
|
1726
|
+
|
|
1727
|
+
def read_from(self, start_timestamp: int, end_timestamp: int | None = None, skip_gaps: bool = True) \
|
|
1728
|
+
-> Iterator[OHLCV]:
|
|
1729
|
+
"""
|
|
1730
|
+
Read bars starting from timestamp, using direct position calculation.
|
|
1731
|
+
|
|
1732
|
+
:param start_timestamp: Start timestamp
|
|
1733
|
+
:param end_timestamp: End timestamp, if None, read until the end
|
|
1734
|
+
:param skip_gaps: Skip gaps in data, the writer fill gaps with the last value with -1 volume,
|
|
1735
|
+
this will skip them (default)
|
|
1736
|
+
:raises ValueError: If start_timestamp is after the last bar
|
|
1737
|
+
"""
|
|
1738
|
+
if not self._size or not self._interval:
|
|
1739
|
+
return
|
|
1740
|
+
|
|
1741
|
+
# Calculate start and end positions
|
|
1742
|
+
start_pos, end_pos = self.get_positions(start_timestamp, end_timestamp)
|
|
1743
|
+
|
|
1744
|
+
# Yield the calculated range
|
|
1745
|
+
for pos in range(start_pos, end_pos):
|
|
1746
|
+
ohlcv = self.read(pos)
|
|
1747
|
+
# Skip gaps if needed
|
|
1748
|
+
if skip_gaps and ohlcv.volume < 0:
|
|
1749
|
+
continue
|
|
1750
|
+
yield ohlcv
|
|
1751
|
+
|
|
1752
|
+
def close(self):
|
|
1753
|
+
"""
|
|
1754
|
+
Close file and memory mapping
|
|
1755
|
+
"""
|
|
1756
|
+
if self._mmap:
|
|
1757
|
+
self._mmap.close()
|
|
1758
|
+
self._mmap = None
|
|
1759
|
+
if self._file:
|
|
1760
|
+
self._file.close()
|
|
1761
|
+
self._file = None
|
|
1762
|
+
self._extra_data = None
|
|
1763
|
+
self._extra_headers = None
|
|
1764
|
+
|
|
1765
|
+
def get_positions(self, start_timestamp: int | None = None, end_timestamp: int | None = None) -> tuple[int, int]:
|
|
1766
|
+
"""
|
|
1767
|
+
Get start and end positions for given timestamps
|
|
1768
|
+
|
|
1769
|
+
:param start_timestamp: Start timestamp
|
|
1770
|
+
:param end_timestamp: End timestamp
|
|
1771
|
+
:return: Tuple of start and end positions
|
|
1772
|
+
"""
|
|
1773
|
+
if not self._size or not self._interval:
|
|
1774
|
+
return 0, 0
|
|
1775
|
+
assert self._start_timestamp is not None
|
|
1776
|
+
|
|
1777
|
+
# Calculate start position
|
|
1778
|
+
if start_timestamp is None:
|
|
1779
|
+
start_pos = 0
|
|
1780
|
+
else:
|
|
1781
|
+
start_diff = start_timestamp - self._start_timestamp
|
|
1782
|
+
if start_diff < 0:
|
|
1783
|
+
start_pos = 0
|
|
1784
|
+
else:
|
|
1785
|
+
start_pos = min(start_diff // self._interval, self._size - 1)
|
|
1786
|
+
|
|
1787
|
+
# Calculate end position if provided
|
|
1788
|
+
if end_timestamp is None:
|
|
1789
|
+
end_pos = self._size
|
|
1790
|
+
else:
|
|
1791
|
+
# If end_timestamp >= actual last record timestamp, use full size
|
|
1792
|
+
# This handles gap-filled files where end_timestamp doesn't align with interval
|
|
1793
|
+
actual_end_ts = self.end_timestamp
|
|
1794
|
+
if actual_end_ts and end_timestamp >= actual_end_ts:
|
|
1795
|
+
end_pos = self._size
|
|
1796
|
+
else:
|
|
1797
|
+
end_diff = end_timestamp - self._start_timestamp
|
|
1798
|
+
end_pos = min(end_diff // self._interval + 1, self._size)
|
|
1799
|
+
|
|
1800
|
+
return start_pos, end_pos
|
|
1801
|
+
|
|
1802
|
+
def get_size(self, start_timestamp: int | None = None, end_timestamp: int | None = None) -> int:
|
|
1803
|
+
"""
|
|
1804
|
+
Get number of records between timestamps
|
|
1805
|
+
|
|
1806
|
+
:param start_timestamp: Start timestamp
|
|
1807
|
+
:param end_timestamp: End timestamp
|
|
1808
|
+
:return: Number of records
|
|
1809
|
+
"""
|
|
1810
|
+
if not self._size or not self._interval:
|
|
1811
|
+
return 0
|
|
1812
|
+
|
|
1813
|
+
start_pos, end_pos = self.get_positions(start_timestamp, end_timestamp)
|
|
1814
|
+
return end_pos - start_pos
|
|
1815
|
+
|
|
1816
|
+
def save_to_csv(self, path: str, as_datetime=False) -> None:
|
|
1817
|
+
"""
|
|
1818
|
+
Save OHLCV data to CSV file
|
|
1819
|
+
|
|
1820
|
+
:param path: Path to the CSV file
|
|
1821
|
+
:param as_datetime: Save timestamp as datetime string
|
|
1822
|
+
"""
|
|
1823
|
+
|
|
1824
|
+
with open(path, 'w') as f:
|
|
1825
|
+
if as_datetime:
|
|
1826
|
+
f.write('time,open,high,low,close,volume\n')
|
|
1827
|
+
else:
|
|
1828
|
+
f.write('timestamp,open,high,low,close,volume\n')
|
|
1829
|
+
for candle in self:
|
|
1830
|
+
# Skip gaps (volume == -1)
|
|
1831
|
+
if candle.volume == -1:
|
|
1832
|
+
continue
|
|
1833
|
+
if as_datetime:
|
|
1834
|
+
f.write(f"{datetime.fromtimestamp(candle.timestamp, UTC)},{_format_float(candle.open)},"
|
|
1835
|
+
f"{_format_float(candle.high)},{_format_float(candle.low)},{_format_float(candle.close)},"
|
|
1836
|
+
f"{_format_float(candle.volume)}\n")
|
|
1837
|
+
else:
|
|
1838
|
+
f.write(f"{candle.timestamp},{_format_float(candle.open)},{_format_float(candle.high)},"
|
|
1839
|
+
f"{_format_float(candle.low)},{_format_float(candle.close)},"
|
|
1840
|
+
f"{_format_float(candle.volume)}\n")
|
|
1841
|
+
|
|
1842
|
+
def save_to_json(self, path: str, as_datetime: bool = False) -> None:
|
|
1843
|
+
"""
|
|
1844
|
+
Save OHLCV data to JSON file.
|
|
1845
|
+
|
|
1846
|
+
The output fmt is either:
|
|
1847
|
+
[
|
|
1848
|
+
{
|
|
1849
|
+
"timestamp": 1234567890, // or "time": "2024-01-07 12:34:56+00:00" if as_datetime is True
|
|
1850
|
+
"open": 100.0,
|
|
1851
|
+
"high": 101.0,
|
|
1852
|
+
"low": 99.0,
|
|
1853
|
+
"close": 100.5,
|
|
1854
|
+
"volume": 1000.0
|
|
1855
|
+
},
|
|
1856
|
+
...
|
|
1857
|
+
]
|
|
1858
|
+
|
|
1859
|
+
:param path: Path to save the JSON file
|
|
1860
|
+
:param as_datetime: If True, convert timestamps to ISO fmt datetime strings
|
|
1861
|
+
"""
|
|
1862
|
+
data = []
|
|
1863
|
+
for candle in self:
|
|
1864
|
+
# Skip gaps (volume == -1)
|
|
1865
|
+
if candle.volume == -1:
|
|
1866
|
+
continue
|
|
1867
|
+
if as_datetime:
|
|
1868
|
+
item = {
|
|
1869
|
+
"time": datetime.fromtimestamp(candle.timestamp, UTC).isoformat(),
|
|
1870
|
+
"open": _format_float(candle.open),
|
|
1871
|
+
"high": _format_float(candle.high),
|
|
1872
|
+
"low": _format_float(candle.low),
|
|
1873
|
+
"close": _format_float(candle.close),
|
|
1874
|
+
"volume": _format_float(candle.volume)
|
|
1875
|
+
}
|
|
1876
|
+
else:
|
|
1877
|
+
item = {
|
|
1878
|
+
"timestamp": candle.timestamp,
|
|
1879
|
+
"open": _format_float(candle.open),
|
|
1880
|
+
"high": _format_float(candle.high),
|
|
1881
|
+
"low": _format_float(candle.low),
|
|
1882
|
+
"close": _format_float(candle.close),
|
|
1883
|
+
"volume": _format_float(candle.volume)
|
|
1884
|
+
}
|
|
1885
|
+
data.append(item)
|
|
1886
|
+
|
|
1887
|
+
with open(path, 'w') as f:
|
|
1888
|
+
json.dump(data, f, indent=2) # Use indent for human-readable fmt # noqa
|