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,1002 @@
1
+ """
2
+ Automatic data file to OHLCV conversion functionality.
3
+
4
+ This module provides automatic detection and conversion of CSV, TXT, and JSON files
5
+ to OHLCV format when needed, eliminating the manual step of running pyne data convert.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ import json
11
+ from enum import Enum
12
+ from datetime import time
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ from pynecore.core.ohlcv_file import OHLCVWriter, OHLCVReader
17
+ from pynecore.utils.file_utils import copy_mtime, is_updated
18
+ from ..lib.timeframe import from_seconds
19
+ from .syminfo import SymInfo, SymInfoInterval, SymInfoSession, default_mincontract
20
+
21
+
22
+ class DataFormatError(Exception):
23
+ """Raised when file format cannot be detected or is unsupported."""
24
+ pass
25
+
26
+
27
+ class ConversionError(Exception):
28
+ """Raised when conversion fails."""
29
+ pass
30
+
31
+
32
+ class SupportedFormats(Enum):
33
+ """Supported data file formats."""
34
+ CSV = 'csv'
35
+ TXT = 'txt'
36
+ JSON = 'json'
37
+
38
+
39
+ class DataConverter:
40
+ """
41
+ Main class for automatic data file conversion.
42
+
43
+ Provides both CLI and programmatic interfaces for converting
44
+ CSV, TXT, and JSON files to OHLCV format automatically.
45
+ """
46
+
47
+ @staticmethod
48
+ def is_conversion_required(source_path: Path, ohlcv_path: Path | None = None) -> bool:
49
+ """
50
+ Check if conversion is required based on file freshness.
51
+
52
+ :param source_path: Path to the source file
53
+ :param ohlcv_path: Path to the OHLCV file (auto-generated if None)
54
+ :return: True if conversion is needed
55
+ """
56
+ path = ohlcv_path if ohlcv_path is not None else source_path.with_suffix('.ohlcv')
57
+
58
+ # If OHLCV file doesn't exist, conversion is needed
59
+ if not path.exists():
60
+ return True
61
+
62
+ # Use existing file utility to check if source is newer
63
+ return is_updated(source_path, path)
64
+
65
+ def convert_to_ohlcv(
66
+ self,
67
+ file_path: Path,
68
+ *,
69
+ force: bool = False,
70
+ provider: str | None = None,
71
+ symbol: str | None = None,
72
+ timezone: str = "UTC"
73
+ ) -> None:
74
+ """
75
+ Convert multiple file formats to OHLCV format.
76
+
77
+ :param file_path: Path to the data file
78
+ :param force: Force conversion even if OHLCV file is up-to-date
79
+ :param provider: Data provider name for OHLCV file naming
80
+ :param symbol: Symbol for OHLCV file naming
81
+ :param timezone: Timezone for timestamp conversion
82
+ :raises FileNotFoundError: If source file doesn't exist
83
+ :raises DataFormatError: If file format is unsupported
84
+ :raises ConversionError: If conversion fails
85
+ """
86
+ if not file_path.exists():
87
+ raise FileNotFoundError(f"Source file not found: {file_path}")
88
+
89
+ # Detect file format
90
+ detected_format = self.detect_format(file_path)
91
+
92
+ # If it's already OHLCV, no conversion needed
93
+ if detected_format == 'ohlcv':
94
+ raise ConversionError(f"Source file is already in OHLCV format: {file_path}")
95
+
96
+ # Check if format is supported
97
+ if detected_format not in SupportedFormats:
98
+ raise DataFormatError(f"Unsupported file format '{detected_format}' for file: {file_path}")
99
+
100
+ # Determine OHLCV output path
101
+ ohlcv_path = file_path.with_suffix('.ohlcv')
102
+
103
+ # Check if conversion is needed
104
+ if not force and not self.is_conversion_required(file_path, ohlcv_path):
105
+ return
106
+
107
+ # Auto-detect symbol and provider from filename if not provided
108
+ if symbol is None or provider is None:
109
+ detected_symbol, detected_provider = self.guess_symbol_from_filename(file_path)
110
+ if symbol is None:
111
+ symbol = detected_symbol
112
+ if provider is None and detected_provider is not None:
113
+ provider = detected_provider
114
+
115
+ # Fallback: peek inside CSV content (Databento puts the symbol in a column)
116
+ if (symbol is None or provider is None) and detected_format == 'csv':
117
+ content_symbol, content_provider = self.guess_symbol_from_csv_content(file_path)
118
+ if symbol is None and content_symbol:
119
+ symbol = content_symbol
120
+ if provider is None and content_provider:
121
+ provider = content_provider
122
+
123
+ # Use default provider if not specified
124
+ if provider is None:
125
+ provider = "CUSTOM"
126
+
127
+ analyzed_tick_size = None
128
+ analyzed_price_scale = None
129
+ analyzed_min_move = None
130
+ detected_timeframe = None
131
+
132
+ # Check if TOML exists and load timezone from it
133
+ # This ensures user modifications to TOML are preserved
134
+ # Note: force parameter applies only to OHLCV regeneration, NOT to TOML
135
+ toml_path = file_path.with_suffix('.toml')
136
+ skip_toml_generation = False
137
+
138
+ if toml_path.exists():
139
+ # noinspection PyBroadException
140
+ try:
141
+ # Load existing TOML to preserve user modifications
142
+ existing_syminfo = SymInfo.load_toml(toml_path)
143
+ timezone = existing_syminfo.timezone # Use TOML timezone for conversion
144
+ skip_toml_generation = True # Don't regenerate TOML (user may have edited it)
145
+ except Exception:
146
+ # If TOML is corrupted, continue with provided/default timezone
147
+ pass
148
+ elif timezone == "UTC" and detected_format == 'csv':
149
+ # If no TOML exists and using default UTC, try to detect timezone from CSV
150
+ detected_tz = self._detect_timezone_from_csv(file_path)
151
+ if detected_tz:
152
+ timezone = detected_tz
153
+
154
+ try:
155
+ # Perform conversion directly to target file with truncate to clear existing data
156
+ with OHLCVWriter(ohlcv_path, truncate=True) as ohlcv_writer:
157
+ if detected_format == 'csv':
158
+ ohlcv_writer.load_from_csv(file_path, tz=timezone)
159
+ elif detected_format == 'json':
160
+ ohlcv_writer.load_from_json(file_path, tz=timezone)
161
+ elif detected_format == 'txt':
162
+ ohlcv_writer.load_from_txt(file_path, tz=timezone)
163
+ else:
164
+ raise ConversionError(f"Unsupported format for conversion: {detected_format}")
165
+
166
+ # Get timeframe directly from writer
167
+ interval = ohlcv_writer.interval
168
+ if interval is None:
169
+ raise ConversionError("Cannot determine timeframe from OHLCV file (less than 2 records)")
170
+ try:
171
+ detected_timeframe = from_seconds(interval)
172
+ except (ValueError, AssertionError):
173
+ raise ConversionError(
174
+ f"Cannot convert interval {ohlcv_writer.interval} seconds to valid timeframe")
175
+
176
+ # Get analyzed tick size data from writer
177
+ analyzed_tick_size = ohlcv_writer.analyzed_tick_size
178
+ analyzed_price_scale = ohlcv_writer.analyzed_price_scale
179
+ analyzed_min_move = ohlcv_writer.analyzed_min_move
180
+
181
+ # Copy modification time from source to maintain freshness
182
+ copy_mtime(file_path, ohlcv_path)
183
+
184
+ # Generate extra fields sidecar CSV if source has extra columns
185
+ extra_csv_path = file_path.with_suffix('.extra.csv')
186
+ if detected_format in ('csv', 'txt'):
187
+ self._generate_extra_csv(file_path, ohlcv_path, extra_csv_path,
188
+ detected_format == 'txt')
189
+ if extra_csv_path.exists():
190
+ copy_mtime(file_path, extra_csv_path)
191
+
192
+ # Generate TOML symbol info file if needed and not already loaded
193
+ # skip_toml_generation is set earlier if TOML already exists (line 129-134)
194
+ if symbol and not skip_toml_generation and (force or not toml_path.exists()):
195
+ # Use analyzed values from OHLCVWriter
196
+ if analyzed_tick_size:
197
+ mintick = analyzed_tick_size
198
+ pricescale = analyzed_price_scale or int(round(1.0 / analyzed_tick_size))
199
+ minmove = analyzed_min_move or 1
200
+ else:
201
+ # Fallback to safe defaults if analysis failed
202
+ mintick = 0.01
203
+ pricescale = 100
204
+ minmove = 1
205
+
206
+ # Determine symbol type based on symbol name patterns
207
+ symbol_upper = symbol.upper()
208
+ symbol_type, currency, base_currency = self.guess_symbol_type(symbol_upper)
209
+
210
+ # Point value cannot be detected from data, always use 1.0
211
+ # Users can manually adjust in the generated TOML file if needed
212
+ pointvalue = 1.0
213
+
214
+ # Quantity step: volume-data analysis, then the heuristic
215
+ # (no provider here that could supply an exchange value)
216
+ mincontract = (ohlcv_writer.analyzed_qty_step
217
+ or default_mincontract(symbol_type, base_currency))
218
+
219
+ # Get opening hours from OHLCVWriter
220
+ analyzed_opening_hours = ohlcv_writer.analyzed_opening_hours
221
+
222
+ if analyzed_opening_hours:
223
+ # Use automatically detected opening hours
224
+ opening_hours = analyzed_opening_hours
225
+ else:
226
+ # Fallback to default based on symbol type (insufficient data or analysis failed)
227
+ opening_hours = self.get_default_opening_hours(symbol_type)
228
+
229
+ # Create session starts and ends
230
+ session_starts = [SymInfoSession(day=1, time=time(0, 0, 0))]
231
+ session_ends = [SymInfoSession(day=7, time=time(23, 59, 59))]
232
+
233
+ # Create SymInfo instance
234
+ # Use provider as prefix (uppercase), default to "CUSTOM" if not provided
235
+ prefix = provider.upper() if provider else "CUSTOM"
236
+ syminfo = SymInfo(
237
+ prefix=prefix,
238
+ description=f"{symbol}",
239
+ ticker=symbol_upper,
240
+ currency=currency,
241
+ basecurrency=base_currency or "USD",
242
+ period=detected_timeframe,
243
+ type=symbol_type,
244
+ mintick=mintick,
245
+ pricescale=int(pricescale),
246
+ minmove=int(minmove),
247
+ pointvalue=pointvalue,
248
+ mincontract=mincontract,
249
+ opening_hours=opening_hours,
250
+ session_starts=session_starts,
251
+ session_ends=session_ends,
252
+ timezone=timezone,
253
+ )
254
+
255
+ # Save using SymInfo's built-in method
256
+ try:
257
+ syminfo.save_toml(toml_path)
258
+ # Copy modification time from source to maintain consistency
259
+ copy_mtime(file_path, toml_path)
260
+ except (OSError, IOError):
261
+ # Don't fail the entire conversion if TOML creation fails
262
+ pass
263
+
264
+ except Exception as e:
265
+ # Clean up output files on error
266
+ for cleanup_path in (ohlcv_path, file_path.with_suffix('.extra.csv')):
267
+ if cleanup_path.exists():
268
+ try:
269
+ cleanup_path.unlink()
270
+ except OSError:
271
+ pass
272
+ raise ConversionError(f"Failed to convert {file_path}: {e}") from e
273
+
274
+ # Column names that are part of standard OHLCV data (not extra fields).
275
+ # ts_event / ts_recv are Databento's timestamp column names.
276
+ _OHLCV_COLUMNS = {
277
+ 'timestamp', 'time', 'date', 'datetime', 'ts_event', 'ts_recv',
278
+ 'open', 'high', 'low', 'close', 'volume',
279
+ }
280
+
281
+ def _generate_extra_csv(
282
+ self,
283
+ source_path: Path,
284
+ ohlcv_path: Path,
285
+ extra_csv_path: Path,
286
+ is_txt: bool = False
287
+ ) -> None:
288
+ """
289
+ Generate a sidecar .extra.csv file with non-OHLCV columns from the source data.
290
+ The sidecar is position-aligned with the binary OHLCV file (including gap-filled rows).
291
+
292
+ :param source_path: Path to the original CSV/TXT file
293
+ :param ohlcv_path: Path to the generated binary OHLCV file
294
+ :param extra_csv_path: Path for the output sidecar CSV
295
+ :param is_txt: True if source is TXT format (auto-detect delimiter)
296
+ """
297
+ # Detect delimiter for TXT files
298
+ delimiter = ','
299
+ if is_txt:
300
+ with open(source_path, 'r') as f:
301
+ first_line = f.readline().strip()
302
+ for delim in ['\t', ';', '|']:
303
+ if delim in first_line:
304
+ delimiter = delim
305
+ break
306
+
307
+ # Read source headers and identify extra columns
308
+ with open(source_path, 'r', newline='') as f:
309
+ reader = csv.reader(f, delimiter=delimiter)
310
+ raw_headers = next(reader, None)
311
+ if not raw_headers:
312
+ return
313
+
314
+ headers_lower = [h.lower().strip() for h in raw_headers]
315
+ extra_indices = [
316
+ i for i, h in enumerate(headers_lower)
317
+ if h not in self._OHLCV_COLUMNS
318
+ ]
319
+
320
+ if not extra_indices:
321
+ return
322
+
323
+ extra_headers = [raw_headers[i].strip() for i in extra_indices]
324
+
325
+ # Collect extra values from all source rows (in order)
326
+ source_extra_rows: list[list[str]] = []
327
+ for row in reader:
328
+ if is_txt:
329
+ row = [field.strip() for field in row]
330
+ extra_row = [row[i] if i < len(row) else '' for i in extra_indices]
331
+ source_extra_rows.append(extra_row)
332
+
333
+ if not source_extra_rows:
334
+ return
335
+
336
+ # Align with OHLCV binary (which may have gap-filled rows)
337
+ with OHLCVReader(ohlcv_path) as ohlcv_reader:
338
+ total_positions = ohlcv_reader.size
339
+ empty_row = [''] * len(extra_headers)
340
+ source_idx = 0
341
+
342
+ with open(extra_csv_path, 'w', newline='') as out_f:
343
+ writer = csv.writer(out_f)
344
+ writer.writerow(extra_headers)
345
+
346
+ for pos in range(total_positions):
347
+ ohlcv = ohlcv_reader.read(pos)
348
+ if ohlcv.volume < 0:
349
+ # Gap-filled row — write empty values
350
+ writer.writerow(empty_row)
351
+ else:
352
+ # Real data row — consume next source row
353
+ if source_idx < len(source_extra_rows):
354
+ writer.writerow(source_extra_rows[source_idx])
355
+ source_idx += 1
356
+ else:
357
+ writer.writerow(empty_row)
358
+
359
+ @staticmethod
360
+ def detect_format(file_path: Path) -> Literal['csv', 'txt', 'json', 'ohlcv', 'unknown']:
361
+ """
362
+ Detect file format by content inspection.
363
+
364
+ :param file_path: Path to the file to analyze
365
+ :return: Detected format
366
+ :raises FileNotFoundError: If file doesn't exist
367
+ :raises DataFormatError: If file cannot be read
368
+ """
369
+ if not file_path.exists():
370
+ raise FileNotFoundError(f"File not found: {file_path}")
371
+
372
+ # First check if it's a valid OHLCV file (binary format)
373
+ try:
374
+ with OHLCVReader(file_path):
375
+ # If we can open it successfully, it's a valid OHLCV file
376
+ return 'ohlcv'
377
+ except (ValueError, OSError, IOError):
378
+ # Not a valid OHLCV file, detect by content
379
+ pass
380
+
381
+ # Detect text-based formats by content
382
+ try:
383
+ with open(file_path, 'r', encoding='utf-8') as f:
384
+ # Read first line for initial analysis
385
+ first_line = f.readline().strip()
386
+
387
+ # Quick JSON check - look for JSON indicators
388
+ if first_line and (first_line.startswith('{') or first_line.startswith('[')):
389
+ # Verify it's valid JSON by reading the whole file
390
+ f.seek(0)
391
+ try:
392
+ json.load(f)
393
+ return 'json'
394
+ except (json.JSONDecodeError, UnicodeDecodeError):
395
+ pass
396
+ # Reset for further analysis if not JSON
397
+ f.seek(0)
398
+ first_line = f.readline().strip()
399
+
400
+ # Check for CSV patterns
401
+ if first_line and ',' in first_line:
402
+ # Count commas to see if it looks like structured data
403
+ comma_count = first_line.count(',')
404
+ if comma_count >= 4: # At least OHLC columns
405
+ return 'csv'
406
+
407
+ # Check for other delimiters (TXT)
408
+ if first_line and any(delim in first_line for delim in ['\t', ';', '|']):
409
+ return 'txt'
410
+
411
+ # Default to CSV if it has any commas
412
+ if first_line and ',' in first_line:
413
+ return 'csv'
414
+
415
+ return 'unknown'
416
+
417
+ except (OSError, IOError, UnicodeDecodeError):
418
+ return 'unknown'
419
+
420
+ @staticmethod
421
+ def get_default_opening_hours(symbol_type: str) -> list[SymInfoInterval]:
422
+ """
423
+ Get default opening hours based on symbol type.
424
+
425
+ :param symbol_type: Type of symbol ('crypto', 'forex', 'stock', or 'other')
426
+ :return: List of SymInfoInterval objects representing default trading hours
427
+ """
428
+ opening_hours = []
429
+
430
+ if symbol_type == 'crypto':
431
+ # 24/7 trading for crypto
432
+ for day in range(1, 8):
433
+ opening_hours.append(SymInfoInterval(
434
+ day=day,
435
+ start=time(0, 0, 0),
436
+ end=time(23, 59, 59)
437
+ ))
438
+ elif symbol_type == 'forex':
439
+ # Forex markets: Sunday 5 PM ET to Friday 5 PM ET (roughly)
440
+ # Using Monday-Friday 00:00-23:59 as approximation
441
+ for day in range(1, 6):
442
+ opening_hours.append(SymInfoInterval(
443
+ day=day,
444
+ start=time(0, 0, 0),
445
+ end=time(23, 59, 59)
446
+ ))
447
+ else:
448
+ # Stock markets and others: typical business hours (Mon-Fri 9:30 AM - 4:00 PM)
449
+ for day in range(1, 6):
450
+ opening_hours.append(SymInfoInterval(
451
+ day=day,
452
+ start=time(9, 30, 0),
453
+ end=time(16, 0, 0)
454
+ ))
455
+
456
+ return opening_hours
457
+
458
+ @staticmethod
459
+ def guess_symbol_from_filename(file_path: Path) -> tuple[str | None, str | None]:
460
+ """
461
+ Guess symbol and provider from filename based on common patterns.
462
+
463
+ :param file_path: Path to the data file
464
+ :return: Tuple of (symbol, provider) or (None, None) if not detected
465
+ """
466
+ filename = file_path.stem # Filename without extension
467
+ filename_upper = filename.upper()
468
+
469
+ # Known provider patterns - these will be detected first
470
+ provider_patterns = {
471
+ 'capitalcom': ['CAPITALCOM'],
472
+ 'capital.com': ['CAPITAL.COM', 'CAPITAL_COM'],
473
+ 'ccxt': ['CCXT'],
474
+ 'tradingview': ['TRADINGVIEW', 'TV'],
475
+ 'mt4': ['MT4', 'METATRADER4'],
476
+ 'mt5': ['MT5', 'METATRADER5'],
477
+ 'binance': ['BINANCE'],
478
+ 'bybit': ['BYBIT'],
479
+ 'coinbase': ['COINBASE'],
480
+ 'kraken': ['KRAKEN'],
481
+ 'oanda': ['OANDA'],
482
+ 'ib': ['IB', 'INTERACTIVE_BROKERS'],
483
+ }
484
+
485
+ # Exchange names for crypto detection
486
+ exchange_names = ['BINANCE', 'BYBIT', 'COINBASE', 'KRAKEN', 'BITFINEX', 'HUOBI', 'OKEX', 'FTX']
487
+
488
+ # Crypto bases and quotes for pair detection
489
+ crypto_bases = ['BTC', 'ETH', 'XRP', 'ADA', 'DOT', 'LINK', 'LTC', 'BCH', 'UNI', 'MATIC',
490
+ 'SOL', 'AVAX', 'LUNA', 'ATOM', 'FTM', 'NEAR', 'ALGO', 'VET', 'FIL', 'ICP']
491
+ # Order matters! Longer suffixes first to avoid false matches (USDT before USD)
492
+ crypto_quotes = ['USDT', 'USDC', 'BUSD', 'TUSD', 'DAI', 'USD', 'EUR', 'GBP', 'JPY', 'BTC', 'ETH']
493
+
494
+ detected_provider = None
495
+ detected_symbol = None
496
+
497
+ # Step 1: Try exchange-based detection first (handles BINANCE_BTC_USDT, BYBIT:BTC:USDT, etc.)
498
+ # Clean separators and split
499
+ cleaned = filename.replace(':', '_').replace('/', '_').replace(',', '_')
500
+ parts = [p.strip() for p in cleaned.split('_') if p.strip()]
501
+
502
+ if len(parts) >= 2 and parts[0].upper() in exchange_names:
503
+ # Exchange detected at the beginning
504
+ detected_provider = parts[0].lower()
505
+
506
+ if len(parts) >= 3:
507
+ # Could be EXCHANGE_BASE_QUOTE format
508
+ if parts[1].upper() in crypto_bases and parts[2].upper() in crypto_quotes:
509
+ # Format: BINANCE_BTC_USDT
510
+ detected_symbol = f"{parts[1].upper()}/{parts[2].upper()}"
511
+ elif parts[1].upper() in crypto_bases:
512
+ # Maybe compact format in later parts: CCXT_BYBIT_BTC_USDT_USDT_1
513
+ # Look for quote in remaining parts
514
+ for i in range(2, len(parts)):
515
+ if parts[i].upper() in crypto_quotes:
516
+ detected_symbol = f"{parts[1].upper()}/{parts[i].upper()}"
517
+ break
518
+ if not detected_symbol:
519
+ # No quote found, use the second part as-is
520
+ detected_symbol = parts[1].upper()
521
+ else:
522
+ # Check if second part is a compact pair (BTCUSDT)
523
+ potential = parts[1].upper()
524
+ for quote in crypto_quotes:
525
+ if potential.endswith(quote):
526
+ base = potential[:-len(quote)]
527
+ if base in crypto_bases:
528
+ detected_symbol = f"{base}/{quote}"
529
+ break
530
+ if not detected_symbol:
531
+ # Use second part as-is
532
+ detected_symbol = parts[1].upper()
533
+ elif len(parts) == 2:
534
+ # EXCHANGE_SYMBOL format
535
+ potential = parts[1].upper()
536
+ # Try to detect compact crypto pair
537
+ for quote in crypto_quotes:
538
+ if potential.endswith(quote):
539
+ base = potential[:-len(quote)]
540
+ if base in crypto_bases:
541
+ detected_symbol = f"{base}/{quote}"
542
+ break
543
+ if not detected_symbol:
544
+ detected_symbol = potential
545
+
546
+ if detected_symbol:
547
+ return detected_symbol, detected_provider
548
+
549
+ # Step 2: Check for explicit provider patterns (handles CAPITALCOM_EURUSD, TV_BTCUSD, etc.)
550
+ # Special case for ccxt_EXCHANGE pattern
551
+ if filename_upper.startswith('CCXT_'):
552
+ # Remove CCXT_ prefix and try to detect exchange and symbol
553
+ temp = filename[5:] # Remove "CCXT_"
554
+ temp_parts = temp.replace(':', '_').replace('/', '_').split('_')
555
+ if len(temp_parts) >= 2 and temp_parts[0].upper() in exchange_names:
556
+ # Format: CCXT_EXCHANGE_... - provider is the exchange name, not 'ccxt'
557
+ detected_provider = temp_parts[0].lower() # Use exchange name as provider
558
+ # Try to extract symbol from remaining parts
559
+ if len(temp_parts) >= 3:
560
+ # Try BASE/QUOTE detection
561
+ for i in range(1, len(temp_parts) - 1):
562
+ if temp_parts[i].upper() in crypto_bases:
563
+ for j in range(i + 1, len(temp_parts)):
564
+ if temp_parts[j].upper() in crypto_quotes:
565
+ detected_symbol = f"{temp_parts[i].upper()}/{temp_parts[j].upper()}"
566
+ return detected_symbol, detected_provider
567
+ # Fallback to simple extraction
568
+ detected_symbol = '_'.join(temp_parts[1:]) if len(temp_parts) > 1 else None
569
+ if detected_symbol:
570
+ return detected_symbol.upper(), detected_provider
571
+ else:
572
+ # No recognized exchange after ccxt_, just use ccxt as provider
573
+ detected_provider = 'ccxt'
574
+ detected_symbol = '_'.join(temp_parts) if temp_parts else None
575
+ if detected_symbol:
576
+ return detected_symbol.upper(), detected_provider
577
+
578
+ for provider, patterns in provider_patterns.items():
579
+ for pattern in patterns:
580
+ if pattern in filename_upper:
581
+ detected_provider = provider
582
+ # Remove provider pattern from filename for symbol detection
583
+ temp_filename = filename
584
+ for p in patterns:
585
+ temp_filename = temp_filename.replace(p, '').replace(p.lower(), '').replace(p.capitalize(), '')
586
+ temp_filename = temp_filename.strip('_').strip('-').strip(',').strip().strip()
587
+
588
+ # TradingView format might have extra parts like ", 30_cbf9d"
589
+ # First remove everything after comma if present
590
+ if ',' in temp_filename:
591
+ temp_filename = temp_filename.split(',')[0].strip()
592
+
593
+ if '_' in temp_filename:
594
+ temp_parts = temp_filename.split('_')
595
+ # Filter out hash-like strings and pure numbers
596
+ symbol_parts = []
597
+ for part in temp_parts:
598
+ part = part.strip()
599
+ if not part:
600
+ continue
601
+ # Skip if looks like a hash or timeframe
602
+ if len(part) <= 6 and any(c.isdigit() for c in part) and any(c.isalpha() for c in part):
603
+ continue
604
+ if part.isdigit():
605
+ continue
606
+ if part.upper() in ['1M', '5M', '15M', '30M', '60M', '1H', '4H', '1D', '1W', 'DAILY',
607
+ 'HOURLY', 'WEEKLY']:
608
+ continue
609
+ symbol_parts.append(part)
610
+ if symbol_parts:
611
+ temp_filename = '_'.join(symbol_parts)
612
+
613
+ if temp_filename:
614
+ # Try to parse the symbol
615
+ temp_upper = temp_filename.upper()
616
+
617
+ # Check for forex pair (6 chars, all letters)
618
+ if len(temp_upper) == 6 and temp_upper.isalpha():
619
+ detected_symbol = temp_upper
620
+ # Check for crypto pair
621
+ elif any(base in temp_upper for base in crypto_bases):
622
+ for quote in crypto_quotes:
623
+ if temp_upper.endswith(quote):
624
+ base = temp_upper[:-len(quote)]
625
+ if base in crypto_bases:
626
+ detected_symbol = f"{base}/{quote}"
627
+ break
628
+ if not detected_symbol:
629
+ detected_symbol = temp_upper
630
+ else:
631
+ detected_symbol = temp_upper
632
+ break
633
+ if detected_provider:
634
+ break
635
+
636
+ # Step 3: If no provider detected, try to infer from symbol pattern
637
+ if not detected_provider and not detected_symbol:
638
+ # Remove common suffixes and prefixes
639
+ clean_name = filename
640
+ for suffix in ['_1M', '_5M', '_15M', '_30M', '_60M', '_1H', '_4H', '_1D', '_1W', '_DAILY', '_HOURLY',
641
+ '_WEEKLY']:
642
+ if clean_name.upper().endswith(suffix):
643
+ clean_name = clean_name[:len(clean_name) - len(suffix)]
644
+ break
645
+
646
+ clean_upper = clean_name.upper()
647
+
648
+ # First check for crypto patterns (more specific)
649
+ for quote in crypto_quotes:
650
+ if clean_upper.endswith(quote):
651
+ base = clean_upper[:-len(quote)]
652
+ if base in crypto_bases:
653
+ detected_symbol = f"{base}/{quote}"
654
+ detected_provider = 'ccxt'
655
+ break
656
+
657
+ # If not crypto, check for 6-letter forex pair
658
+ if not detected_symbol and len(clean_upper) == 6 and clean_upper.isalpha():
659
+ detected_symbol = clean_upper
660
+ detected_provider = 'forex'
661
+
662
+ # If still no match, check for separator-based pairs
663
+ if not detected_symbol:
664
+ # Try underscore or dash separator
665
+ if '_' in clean_name:
666
+ parts = clean_name.split('_')
667
+ elif '-' in clean_name:
668
+ parts = clean_name.split('-')
669
+ else:
670
+ parts = []
671
+
672
+ if len(parts) == 2:
673
+ if len(parts[0]) == 3 and len(parts[1]) == 3 and parts[0].isalpha() and parts[1].isalpha():
674
+ # Likely forex: EUR_USD or EUR-USD
675
+ detected_symbol = parts[0].upper() + parts[1].upper()
676
+ detected_provider = 'forex'
677
+ elif parts[0].upper() in crypto_bases and parts[1].upper() in crypto_quotes:
678
+ # Crypto: BTC_USDT or BTC-USDT
679
+ detected_symbol = f"{parts[0].upper()}/{parts[1].upper()}"
680
+ detected_provider = 'ccxt'
681
+
682
+ # Last resort - if it's a known ticker (must have at least one letter)
683
+ if not detected_symbol and len(clean_upper) >= 3 and clean_upper.isalnum() and any(
684
+ c.isalpha() for c in clean_upper):
685
+ detected_symbol = clean_upper
686
+
687
+ return detected_symbol, detected_provider
688
+
689
+ @staticmethod
690
+ def guess_symbol_from_csv_content(file_path: Path) -> tuple[str | None, str | None]:
691
+ """
692
+ Inspect the first row of a CSV file for symbol/provider hints that
693
+ cannot be derived from the filename.
694
+
695
+ Recognises Databento OHLCV exports (``ts_event`` / ``ts_recv`` timestamp
696
+ column tags the provider as ``databento``), and reads the ``symbol`` or
697
+ ``ticker`` column from the first data row when present.
698
+
699
+ :param file_path: Path to the CSV file
700
+ :return: Tuple of (symbol, provider). Either may be ``None``.
701
+ """
702
+ try:
703
+ with open(file_path, 'r', newline='') as f:
704
+ reader = csv.reader(f)
705
+ headers = next(reader, None)
706
+ if not headers:
707
+ return None, None
708
+
709
+ headers_lower = [h.lower().strip() for h in headers]
710
+
711
+ # Provider hint: Databento ships ts_event / ts_recv columns
712
+ provider: str | None = None
713
+ if 'ts_event' in headers_lower or 'ts_recv' in headers_lower:
714
+ provider = 'databento'
715
+
716
+ # Symbol hint: first matching column from the first data row
717
+ symbol: str | None = None
718
+ for col in ('symbol', 'ticker'):
719
+ if col in headers_lower:
720
+ idx = headers_lower.index(col)
721
+ first_row = next(reader, None)
722
+ if first_row and idx < len(first_row):
723
+ value = first_row[idx].strip()
724
+ if value:
725
+ symbol = value
726
+ break
727
+
728
+ return symbol, provider
729
+ except (OSError, IOError, UnicodeDecodeError, csv.Error):
730
+ return None, None
731
+
732
+ @staticmethod
733
+ def guess_symbol_type(symbol_upper: str) -> tuple[Literal["forex", "crypto", "other"], str, str | None]:
734
+ """
735
+ Guess symbol type and extract currency information based on common patterns.
736
+
737
+ :param symbol_upper: Uppercase symbol string
738
+ :return: Tuple of (symbol_type, currency, base_currency)
739
+ """
740
+ # Common forex pairs - check these first for accurate detection
741
+ forex_pairs = {
742
+ 'EURUSD', 'GBPUSD', 'USDJPY', 'USDCHF', 'AUDUSD', 'USDCAD', 'NZDUSD',
743
+ 'EURGBP', 'EURJPY', 'GBPJPY', 'EURCHF', 'EURAUD', 'EURCAD', 'EURNZD',
744
+ 'GBPCHF', 'GBPAUD', 'GBPCAD', 'GBPNZD', 'AUDJPY', 'AUDCHF', 'AUDCAD',
745
+ 'AUDNZD', 'CADJPY', 'CADCHF', 'NZDJPY', 'NZDCHF', 'NZDCAD', 'CHFJPY',
746
+ 'EUR/USD', 'GBP/USD', 'USD/JPY', 'USD/CHF', 'AUD/USD', 'USD/CAD', 'NZD/USD'
747
+ }
748
+
749
+ # Common crypto symbols
750
+ crypto_symbols = {
751
+ 'BTC', 'ETH', 'BNB', 'ADA', 'SOL', 'DOT', 'DOGE', 'AVAX', 'LUNA', 'SHIB',
752
+ 'MATIC', 'UNI', 'LINK', 'LTC', 'ALGO', 'BCH', 'XLM', 'VET', 'ATOM', 'FIL',
753
+ 'TRX', 'ETC', 'XMR', 'MANA', 'SAND', 'HBAR', 'EGLD', 'THETA', 'FTM', 'XTZ',
754
+ 'AAVE', 'AXS', 'CAKE', 'CRO', 'NEAR', 'KSM', 'ENJ', 'CHZ', 'SUSHI', 'SNX'
755
+ }
756
+
757
+ # Initialize default values
758
+ symbol_type: Literal["forex", "crypto", "other"] = 'other'
759
+ currency = 'USD'
760
+ base_currency: str | None = None
761
+
762
+ # Strip provider prefix ("BINANCE:BTCUSDT" -> "BTCUSDT") and TV ticker
763
+ # suffixes (".P" perpetual, ".F" continuous future, etc.) so the base
764
+ # asset extraction below sees only the actual pair. Without this,
765
+ # "BTCUSDT.P" -> base_currency "BTC.P", which trips downstream code
766
+ # that expects an exact-match basecurrency like "BTC".
767
+ core_symbol = symbol_upper.rsplit(':', 1)[-1].split('.', 1)[0]
768
+ clean_symbol = core_symbol.replace('_', '').replace('-', '').strip()
769
+
770
+ # Check if it's a direct forex pair match (check both with and without slash)
771
+ if clean_symbol in forex_pairs or symbol_upper in forex_pairs or \
772
+ any(pair.replace('/', '') in clean_symbol for pair in forex_pairs):
773
+ symbol_type = 'forex'
774
+ # Extract currencies from forex pair - more robust extraction
775
+ matched = False
776
+ for pair in forex_pairs:
777
+ clean_pair = pair.replace('/', '')
778
+ # Check both versions
779
+ if clean_pair in clean_symbol or pair == symbol_upper:
780
+ # Found exact match
781
+ if '/' in pair:
782
+ parts = pair.split('/')
783
+ base_currency = parts[0]
784
+ currency = parts[1]
785
+ else:
786
+ base_currency = pair[:3]
787
+ currency = pair[3:6]
788
+ matched = True
789
+ break
790
+
791
+ if not matched:
792
+ # Fallback extraction for forex
793
+ if 'EUR' in clean_symbol:
794
+ base_currency = 'EUR'
795
+ remaining = clean_symbol.replace('EUR', '')
796
+ currency = remaining[:3] if len(remaining) >= 3 else 'USD'
797
+ elif 'GBP' in clean_symbol:
798
+ base_currency = 'GBP'
799
+ remaining = clean_symbol.replace('GBP', '')
800
+ currency = remaining[:3] if len(remaining) >= 3 else 'USD'
801
+ elif clean_symbol.startswith('USD'):
802
+ base_currency = 'USD'
803
+ currency = clean_symbol[3:6] if len(clean_symbol) >= 6 else 'EUR'
804
+ else:
805
+ # Try to extract 3-letter codes
806
+ base_currency = clean_symbol[:3] if len(clean_symbol) >= 3 else 'EUR'
807
+ currency = clean_symbol[3:6] if len(clean_symbol) >= 6 else 'USD'
808
+
809
+ # Check if symbol contains '/' separator (explicit format)
810
+ elif '/' in symbol_upper:
811
+ parts = symbol_upper.split('/')
812
+ if len(parts) == 2:
813
+ left_part = parts[0].strip()
814
+ right_part = parts[1].strip()
815
+
816
+ # Check if it's crypto (contains crypto symbols or stable coins)
817
+ if any(crypto in left_part for crypto in crypto_symbols) or \
818
+ right_part in ['USDT', 'USDC', 'BUSD', 'DAI', 'UST', 'TUSD']:
819
+ symbol_type = 'crypto'
820
+ currency = right_part
821
+ base_currency = left_part
822
+ # Check if it's forex (both parts are 3-letter currency codes)
823
+ elif len(left_part) == 3 and len(right_part) == 3 and \
824
+ left_part.isalpha() and right_part.isalpha():
825
+ symbol_type = 'forex'
826
+ base_currency = left_part
827
+ currency = right_part
828
+ else:
829
+ # Default to crypto for slash notation
830
+ symbol_type = 'crypto'
831
+ currency = right_part
832
+ base_currency = left_part
833
+
834
+ # Check if it's crypto by matching known crypto symbols
835
+ elif any(crypto in clean_symbol for crypto in crypto_symbols):
836
+ symbol_type = 'crypto'
837
+ # Try to extract the quote currency
838
+ if 'USDT' in clean_symbol:
839
+ currency = 'USDT'
840
+ base_currency = clean_symbol.replace('USDT', '')
841
+ elif 'USDC' in clean_symbol:
842
+ currency = 'USDC'
843
+ base_currency = clean_symbol.replace('USDC', '')
844
+ elif 'BUSD' in clean_symbol:
845
+ currency = 'BUSD'
846
+ base_currency = clean_symbol.replace('BUSD', '')
847
+ elif 'USD' in clean_symbol:
848
+ currency = 'USD'
849
+ base_currency = clean_symbol.replace('USD', '')
850
+ else:
851
+ # Try to find the crypto part
852
+ for crypto in crypto_symbols:
853
+ if crypto in clean_symbol:
854
+ base_currency = crypto
855
+ currency = clean_symbol.replace(crypto, '') or 'USDT'
856
+ break
857
+ else:
858
+ currency = 'USDT'
859
+ base_currency = clean_symbol
860
+
861
+ if not base_currency or base_currency == currency:
862
+ base_currency = clean_symbol[:3] if len(clean_symbol) >= 3 else 'BTC'
863
+
864
+ # Check if it looks like a forex pair (6 letters, no special chars)
865
+ elif len(clean_symbol) == 6 and clean_symbol.isalpha():
866
+ # Could be forex like EURUSD or crypto like BTCUSD
867
+ potential_base = clean_symbol[:3]
868
+ potential_quote = clean_symbol[3:6]
869
+
870
+ # Common forex currencies
871
+ forex_currencies = {'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'NZD'}
872
+
873
+ if potential_base in forex_currencies and potential_quote in forex_currencies:
874
+ symbol_type = 'forex'
875
+ base_currency = potential_base
876
+ currency = potential_quote
877
+ else:
878
+ # Default to other for unknown 6-letter symbols
879
+ symbol_type = 'other'
880
+ currency = 'USD'
881
+ base_currency = None
882
+
883
+ else:
884
+ # Default to other for everything else
885
+ symbol_type = 'other'
886
+ currency = 'USD'
887
+ base_currency = None
888
+
889
+ return symbol_type, currency, base_currency
890
+
891
+ @staticmethod
892
+ def _detect_timezone_from_csv(file_path: Path) -> str | None:
893
+ """
894
+ Detect timezone from CSV timestamps by analyzing all timezone offsets.
895
+
896
+ Examines ALL timestamps in the CSV to find timezone patterns.
897
+ If timestamps contain timezone info (e.g., +0000, -0500), attempts to map
898
+ to a canonical timezone name based on offset patterns.
899
+
900
+ :param file_path: Path to CSV file
901
+ :return: Detected timezone string or None if cannot detect
902
+ """
903
+ import csv
904
+ import re
905
+
906
+ # Mapping of DST patterns to timezone names
907
+ # Format: (offset1, offset2) -> timezone (alphabetically sorted as strings)
908
+ # Note: String sort differs from numeric! '-0400' < '-0500' (string sort)
909
+ dst_patterns = {
910
+ ('-0400', '-0500'): 'US/Eastern', # EDT/EST (string sorted)
911
+ ('-0500', '-0600'): 'US/Central', # CDT/CST (string sorted)
912
+ ('-0600', '-0700'): 'US/Mountain', # MDT/MST (string sorted)
913
+ ('-0700', '-0800'): 'US/Pacific', # PDT/PST (string sorted)
914
+ ('+0000', '+0100'): 'Europe/London', # GMT/BST
915
+ ('+0100', '+0200'): 'Europe/Paris', # CET/CEST (also Berlin, Rome, etc.)
916
+ }
917
+
918
+ # noinspection PyBroadException
919
+ try:
920
+ with open(file_path, 'r') as f:
921
+ reader = csv.reader(f)
922
+ headers = [h.lower() for h in next(reader)]
923
+
924
+ # Find timestamp column
925
+ timestamp_idx = None
926
+ for idx, header in enumerate(headers):
927
+ if header in ['time', 'timestamp', 'date', 'datetime']:
928
+ timestamp_idx = idx
929
+ break
930
+
931
+ if timestamp_idx is None:
932
+ return None
933
+
934
+ # Collect ALL unique offsets
935
+ unique_offsets = set()
936
+ offset_pattern = re.compile(r'([+-]\d{2}):?(\d{2})$')
937
+
938
+ for row in reader:
939
+ if timestamp_idx >= len(row):
940
+ continue
941
+
942
+ timestamp_str = row[timestamp_idx]
943
+ match = offset_pattern.search(timestamp_str)
944
+ if match:
945
+ # Normalize offset format to +0000 or -0500
946
+ offset = f"{match.group(1)}{match.group(2)}"
947
+ unique_offsets.add(offset)
948
+
949
+ if not unique_offsets:
950
+ # No timezone info found in timestamps
951
+ return None
952
+
953
+ # Convert to sorted list for deterministic behavior
954
+ unique_offsets_list = sorted(unique_offsets)
955
+
956
+ if len(unique_offsets_list) == 1:
957
+ # Single offset throughout - either fixed timezone or data from one season
958
+ offset = unique_offsets_list[0]
959
+
960
+ # Map to common fixed or single-season timezones
961
+ # Note: For single offset, we return the timezone that uses this offset
962
+ # (could be winter-only or summer-only data)
963
+ if offset == '+0000':
964
+ return 'UTC'
965
+ elif offset == '-0500':
966
+ return 'US/Eastern' # EST (winter) - could also be year-round EST
967
+ elif offset == '-0400':
968
+ return 'US/Eastern' # EDT (summer) - could also be year-round Atlantic
969
+ elif offset == '-0600':
970
+ return 'US/Central'
971
+ elif offset == '-0700':
972
+ return 'US/Mountain'
973
+ elif offset == '-0800':
974
+ return 'US/Pacific'
975
+ elif offset == '+0100':
976
+ return 'Europe/Paris' # CET (winter)
977
+ elif offset == '+0200':
978
+ return 'Europe/Paris' # CEST (summer)
979
+ else:
980
+ # Unknown offset - return as-is (e.g., "+0530" for IST)
981
+ return offset
982
+
983
+ elif len(unique_offsets_list) == 2:
984
+ # Two offsets - DST transition detected
985
+ sorted_offsets: tuple[str, str] = (unique_offsets_list[0], unique_offsets_list[1])
986
+
987
+ # Try to match known DST pattern
988
+ if sorted_offsets in dst_patterns:
989
+ return dst_patterns[sorted_offsets]
990
+
991
+ # Unknown DST pattern - return the first (smaller) offset
992
+ # This gives consistent behavior: winter offset for northern hemisphere
993
+ return sorted_offsets[0]
994
+
995
+ else:
996
+ # More than 2 offsets - unusual (corrupted data or multi-timezone data)
997
+ # Return the most negative offset (likely to be standard time)
998
+ return unique_offsets_list[0]
999
+
1000
+ except Exception:
1001
+ # If detection fails, return None (will use default UTC)
1002
+ return None