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.
Files changed (261) hide show
  1. opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
  2. opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
  3. opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
  4. opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
  5. opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
  6. opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
  7. opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
  8. pynecore/__init__.py +6 -0
  9. pynecore/cli/__init__.py +2 -0
  10. pynecore/cli/app.py +238 -0
  11. pynecore/cli/commands/__init__.py +343 -0
  12. pynecore/cli/commands/benchmark.py +186 -0
  13. pynecore/cli/commands/compile.py +198 -0
  14. pynecore/cli/commands/data.py +857 -0
  15. pynecore/cli/commands/debug.py +63 -0
  16. pynecore/cli/commands/optimize.py +956 -0
  17. pynecore/cli/commands/plugin.py +242 -0
  18. pynecore/cli/commands/run.py +2006 -0
  19. pynecore/cli/pluggable.py +132 -0
  20. pynecore/cli/utils/__init__.py +0 -0
  21. pynecore/cli/utils/api_error_handler.py +168 -0
  22. pynecore/cli/utils/broker_picker.py +330 -0
  23. pynecore/cli/utils/error_hook.py +28 -0
  24. pynecore/cli/utils/keyreader.py +178 -0
  25. pynecore/cli/utils/provider_picker.py +19 -0
  26. pynecore/cli/utils/symbol_browser.py +1149 -0
  27. pynecore/core/__init__.py +0 -0
  28. pynecore/core/aggregator.py +257 -0
  29. pynecore/core/bar_magnifier.py +168 -0
  30. pynecore/core/broker/__init__.py +64 -0
  31. pynecore/core/broker/defaults.py +113 -0
  32. pynecore/core/broker/disappearance.py +927 -0
  33. pynecore/core/broker/emulator.py +345 -0
  34. pynecore/core/broker/exceptions.py +346 -0
  35. pynecore/core/broker/idempotency.py +401 -0
  36. pynecore/core/broker/intent_builder.py +334 -0
  37. pynecore/core/broker/journal.py +1785 -0
  38. pynecore/core/broker/models.py +1600 -0
  39. pynecore/core/broker/native_failsafe_manager.py +1436 -0
  40. pynecore/core/broker/one_way_emulator.py +1128 -0
  41. pynecore/core/broker/position.py +787 -0
  42. pynecore/core/broker/run_identity.py +126 -0
  43. pynecore/core/broker/software_entry_stop_engine.py +351 -0
  44. pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
  45. pynecore/core/broker/spot_inventory.py +1327 -0
  46. pynecore/core/broker/storage.py +2655 -0
  47. pynecore/core/broker/store_helpers.py +2161 -0
  48. pynecore/core/broker/sync_engine.py +16070 -0
  49. pynecore/core/broker/validation.py +382 -0
  50. pynecore/core/class_property.py +7 -0
  51. pynecore/core/config.py +392 -0
  52. pynecore/core/csv_file.py +547 -0
  53. pynecore/core/currency.py +262 -0
  54. pynecore/core/data_converter.py +1002 -0
  55. pynecore/core/datetime.py +296 -0
  56. pynecore/core/download_info.py +71 -0
  57. pynecore/core/download_runner.py +274 -0
  58. pynecore/core/htf_aggregator.py +181 -0
  59. pynecore/core/import_hook.py +358 -0
  60. pynecore/core/instance_state.py +494 -0
  61. pynecore/core/live_ltf_collector.py +442 -0
  62. pynecore/core/live_ltf_window.py +189 -0
  63. pynecore/core/live_runner.py +1347 -0
  64. pynecore/core/module_property.py +26 -0
  65. pynecore/core/ohlcv_file.py +1888 -0
  66. pynecore/core/overload.py +371 -0
  67. pynecore/core/pine_cast.py +113 -0
  68. pynecore/core/pine_export.py +95 -0
  69. pynecore/core/pine_method.py +244 -0
  70. pynecore/core/pine_range.py +86 -0
  71. pynecore/core/pine_udt.py +69 -0
  72. pynecore/core/plugin/__init__.py +394 -0
  73. pynecore/core/plugin/broker.py +781 -0
  74. pynecore/core/plugin/cli.py +96 -0
  75. pynecore/core/plugin/live_provider.py +208 -0
  76. pynecore/core/plugin/provider.py +331 -0
  77. pynecore/core/provider_string.py +148 -0
  78. pynecore/core/random.py +40 -0
  79. pynecore/core/resampler.py +686 -0
  80. pynecore/core/safe_convert.py +64 -0
  81. pynecore/core/script.py +1011 -0
  82. pynecore/core/script_runner.py +3202 -0
  83. pynecore/core/security.py +1749 -0
  84. pynecore/core/security_process.py +1253 -0
  85. pynecore/core/security_shm.py +456 -0
  86. pynecore/core/series.py +417 -0
  87. pynecore/core/strategy_stats.py +669 -0
  88. pynecore/core/symbol_map.py +134 -0
  89. pynecore/core/syminfo.py +505 -0
  90. pynecore/core/viz.py +591 -0
  91. pynecore/lib/__init__.py +1771 -0
  92. pynecore/lib/_fixnan.py +32 -0
  93. pynecore/lib/_math_stateful.py +202 -0
  94. pynecore/lib/_timeframe_change.py +101 -0
  95. pynecore/lib/adjustment.py +6 -0
  96. pynecore/lib/alert.py +39 -0
  97. pynecore/lib/alert.pyi +14 -0
  98. pynecore/lib/array.py +1051 -0
  99. pynecore/lib/barmerge.py +60 -0
  100. pynecore/lib/barstate.py +30 -0
  101. pynecore/lib/box.py +415 -0
  102. pynecore/lib/chart.py +128 -0
  103. pynecore/lib/color.py +152 -0
  104. pynecore/lib/color.pyi +50 -0
  105. pynecore/lib/currency.py +62 -0
  106. pynecore/lib/dayofweek.py +36 -0
  107. pynecore/lib/dayofweek.pyi +18 -0
  108. pynecore/lib/display.py +8 -0
  109. pynecore/lib/dividends.py +9 -0
  110. pynecore/lib/earnings.py +11 -0
  111. pynecore/lib/extend.py +6 -0
  112. pynecore/lib/font.py +5 -0
  113. pynecore/lib/footprint.py +79 -0
  114. pynecore/lib/format.py +11 -0
  115. pynecore/lib/hline.py +67 -0
  116. pynecore/lib/hline.pyi +24 -0
  117. pynecore/lib/label.py +409 -0
  118. pynecore/lib/line.py +433 -0
  119. pynecore/lib/linefill.py +93 -0
  120. pynecore/lib/location.py +11 -0
  121. pynecore/lib/log.py +362 -0
  122. pynecore/lib/map.py +150 -0
  123. pynecore/lib/math.py +385 -0
  124. pynecore/lib/matrix.py +708 -0
  125. pynecore/lib/order.py +8 -0
  126. pynecore/lib/pivotpointtype.py +8 -0
  127. pynecore/lib/plot.py +95 -0
  128. pynecore/lib/plot.pyi +33 -0
  129. pynecore/lib/polyline.py +91 -0
  130. pynecore/lib/position.py +15 -0
  131. pynecore/lib/request.py +281 -0
  132. pynecore/lib/runtime.py +5 -0
  133. pynecore/lib/scale.py +9 -0
  134. pynecore/lib/session.py +267 -0
  135. pynecore/lib/session.pyi +12 -0
  136. pynecore/lib/shape.py +18 -0
  137. pynecore/lib/size.py +12 -0
  138. pynecore/lib/splits.py +4 -0
  139. pynecore/lib/strategy/__init__.py +4778 -0
  140. pynecore/lib/strategy/closedtrades.py +347 -0
  141. pynecore/lib/strategy/closedtrades.pyi +53 -0
  142. pynecore/lib/strategy/commission.py +9 -0
  143. pynecore/lib/strategy/direction.py +9 -0
  144. pynecore/lib/strategy/oca.py +13 -0
  145. pynecore/lib/strategy/opentrades.py +281 -0
  146. pynecore/lib/strategy/opentrades.pyi +49 -0
  147. pynecore/lib/strategy/risk.py +109 -0
  148. pynecore/lib/string.py +649 -0
  149. pynecore/lib/syminfo.py +84 -0
  150. pynecore/lib/ta.py +2230 -0
  151. pynecore/lib/table.py +290 -0
  152. pynecore/lib/text.py +17 -0
  153. pynecore/lib/ticker.py +207 -0
  154. pynecore/lib/timeframe.py +293 -0
  155. pynecore/lib/volume_row.py +67 -0
  156. pynecore/lib/xloc.py +4 -0
  157. pynecore/lib/yloc.py +5 -0
  158. pynecore/providers/__init__.py +0 -0
  159. pynecore/providers/ccxt.py +664 -0
  160. pynecore/providers/replay.py +187 -0
  161. pynecore/pynesys/__init__.py +0 -0
  162. pynecore/pynesys/api.py +498 -0
  163. pynecore/pynesys/compiler.py +112 -0
  164. pynecore/standalone.py +99 -0
  165. pynecore/testing/__init__.py +1 -0
  166. pynecore/testing/broker_lab/__init__.py +41 -0
  167. pynecore/testing/broker_lab/__main__.py +5 -0
  168. pynecore/testing/broker_lab/cli.py +87 -0
  169. pynecore/testing/broker_lab/generate.py +47 -0
  170. pynecore/testing/broker_lab/model.py +84 -0
  171. pynecore/testing/broker_lab/reference.py +645 -0
  172. pynecore/testing/broker_lab/runner.py +372 -0
  173. pynecore/testing/broker_lab/scheduler.py +50 -0
  174. pynecore/testing/broker_lab/subprocess.py +73 -0
  175. pynecore/transformers/__init__.py +0 -0
  176. pynecore/transformers/builtin_shadow.py +136 -0
  177. pynecore/transformers/closure_arguments_transformer.py +428 -0
  178. pynecore/transformers/display_rewrite.py +140 -0
  179. pynecore/transformers/dynamic_default.py +147 -0
  180. pynecore/transformers/function_isolation.py +757 -0
  181. pynecore/transformers/import_lifter.py +61 -0
  182. pynecore/transformers/import_normalizer.py +328 -0
  183. pynecore/transformers/inline_series_hoist.py +178 -0
  184. pynecore/transformers/input_transformer.py +175 -0
  185. pynecore/transformers/lib_series.py +201 -0
  186. pynecore/transformers/locations.py +70 -0
  187. pynecore/transformers/module_properties.json +3387 -0
  188. pynecore/transformers/module_property.py +221 -0
  189. pynecore/transformers/ne_guard.py +70 -0
  190. pynecore/transformers/persistent.py +320 -0
  191. pynecore/transformers/persistent_series.py +76 -0
  192. pynecore/transformers/safe_convert_transformer.py +97 -0
  193. pynecore/transformers/safe_division_transformer.py +95 -0
  194. pynecore/transformers/script_requirements.py +308 -0
  195. pynecore/transformers/security.py +752 -0
  196. pynecore/transformers/security_instantiation.py +274 -0
  197. pynecore/transformers/series.py +275 -0
  198. pynecore/transformers/slot_layout.py +381 -0
  199. pynecore/transformers/type_checking_stripper.py +25 -0
  200. pynecore/transformers/unused_series_detector.py +267 -0
  201. pynecore/types/__init__.py +21 -0
  202. pynecore/types/alert.py +5 -0
  203. pynecore/types/barmerge.py +5 -0
  204. pynecore/types/base.py +39 -0
  205. pynecore/types/box.py +37 -0
  206. pynecore/types/chart.py +17 -0
  207. pynecore/types/color.py +107 -0
  208. pynecore/types/currency.py +5 -0
  209. pynecore/types/datetime.py +6 -0
  210. pynecore/types/display.py +5 -0
  211. pynecore/types/dividends.py +5 -0
  212. pynecore/types/earnings.py +5 -0
  213. pynecore/types/extend.py +5 -0
  214. pynecore/types/font.py +5 -0
  215. pynecore/types/footprint.py +41 -0
  216. pynecore/types/format.py +5 -0
  217. pynecore/types/hline.py +24 -0
  218. pynecore/types/ib_persistent.py +8 -0
  219. pynecore/types/ib_persistent.pyi +10 -0
  220. pynecore/types/label.py +35 -0
  221. pynecore/types/line.py +32 -0
  222. pynecore/types/linefill.py +13 -0
  223. pynecore/types/location.py +5 -0
  224. pynecore/types/matrix.py +999 -0
  225. pynecore/types/na.py +237 -0
  226. pynecore/types/na.pyi +83 -0
  227. pynecore/types/ohlcv.py +12 -0
  228. pynecore/types/order.py +5 -0
  229. pynecore/types/persistent.py +8 -0
  230. pynecore/types/persistent.pyi +13 -0
  231. pynecore/types/pine_types.py +11 -0
  232. pynecore/types/pine_types.pyi +15 -0
  233. pynecore/types/pivotpointtype.py +5 -0
  234. pynecore/types/plot.py +12 -0
  235. pynecore/types/plot_meta.py +60 -0
  236. pynecore/types/polyline.py +40 -0
  237. pynecore/types/position.py +5 -0
  238. pynecore/types/scale.py +5 -0
  239. pynecore/types/script_type.py +15 -0
  240. pynecore/types/series.py +23 -0
  241. pynecore/types/series.pyi +19 -0
  242. pynecore/types/session.py +35 -0
  243. pynecore/types/shape.py +5 -0
  244. pynecore/types/size.py +5 -0
  245. pynecore/types/source.py +33 -0
  246. pynecore/types/splits.py +5 -0
  247. pynecore/types/strategy.py +45 -0
  248. pynecore/types/table.py +87 -0
  249. pynecore/types/text.py +13 -0
  250. pynecore/types/type_checker.py +7 -0
  251. pynecore/types/type_checker.pyi +48 -0
  252. pynecore/types/volume_row.py +36 -0
  253. pynecore/types/weekdays.py +11 -0
  254. pynecore/types/xloc.py +5 -0
  255. pynecore/types/yloc.py +5 -0
  256. pynecore/utils/__init__.py +0 -0
  257. pynecore/utils/file_utils.py +50 -0
  258. pynecore/utils/rich/__init__.py +0 -0
  259. pynecore/utils/rich/date_column.py +25 -0
  260. pynecore/utils/sequence_view.py +92 -0
  261. 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