opencode-pyneruntime 6.6.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencode_pyneruntime-6.6.4.dist-info/METADATA +281 -0
- opencode_pyneruntime-6.6.4.dist-info/RECORD +261 -0
- opencode_pyneruntime-6.6.4.dist-info/WHEEL +5 -0
- opencode_pyneruntime-6.6.4.dist-info/entry_points.txt +6 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/LICENSE +201 -0
- opencode_pyneruntime-6.6.4.dist-info/licenses/NOTICE +21 -0
- opencode_pyneruntime-6.6.4.dist-info/top_level.txt +1 -0
- pynecore/__init__.py +6 -0
- pynecore/cli/__init__.py +2 -0
- pynecore/cli/app.py +238 -0
- pynecore/cli/commands/__init__.py +343 -0
- pynecore/cli/commands/benchmark.py +186 -0
- pynecore/cli/commands/compile.py +198 -0
- pynecore/cli/commands/data.py +857 -0
- pynecore/cli/commands/debug.py +63 -0
- pynecore/cli/commands/optimize.py +956 -0
- pynecore/cli/commands/plugin.py +242 -0
- pynecore/cli/commands/run.py +2006 -0
- pynecore/cli/pluggable.py +132 -0
- pynecore/cli/utils/__init__.py +0 -0
- pynecore/cli/utils/api_error_handler.py +168 -0
- pynecore/cli/utils/broker_picker.py +330 -0
- pynecore/cli/utils/error_hook.py +28 -0
- pynecore/cli/utils/keyreader.py +178 -0
- pynecore/cli/utils/provider_picker.py +19 -0
- pynecore/cli/utils/symbol_browser.py +1149 -0
- pynecore/core/__init__.py +0 -0
- pynecore/core/aggregator.py +257 -0
- pynecore/core/bar_magnifier.py +168 -0
- pynecore/core/broker/__init__.py +64 -0
- pynecore/core/broker/defaults.py +113 -0
- pynecore/core/broker/disappearance.py +927 -0
- pynecore/core/broker/emulator.py +345 -0
- pynecore/core/broker/exceptions.py +346 -0
- pynecore/core/broker/idempotency.py +401 -0
- pynecore/core/broker/intent_builder.py +334 -0
- pynecore/core/broker/journal.py +1785 -0
- pynecore/core/broker/models.py +1600 -0
- pynecore/core/broker/native_failsafe_manager.py +1436 -0
- pynecore/core/broker/one_way_emulator.py +1128 -0
- pynecore/core/broker/position.py +787 -0
- pynecore/core/broker/run_identity.py +126 -0
- pynecore/core/broker/software_entry_stop_engine.py +351 -0
- pynecore/core/broker/software_partial_bracket_engine.py +1379 -0
- pynecore/core/broker/spot_inventory.py +1327 -0
- pynecore/core/broker/storage.py +2655 -0
- pynecore/core/broker/store_helpers.py +2161 -0
- pynecore/core/broker/sync_engine.py +16070 -0
- pynecore/core/broker/validation.py +382 -0
- pynecore/core/class_property.py +7 -0
- pynecore/core/config.py +392 -0
- pynecore/core/csv_file.py +547 -0
- pynecore/core/currency.py +262 -0
- pynecore/core/data_converter.py +1002 -0
- pynecore/core/datetime.py +296 -0
- pynecore/core/download_info.py +71 -0
- pynecore/core/download_runner.py +274 -0
- pynecore/core/htf_aggregator.py +181 -0
- pynecore/core/import_hook.py +358 -0
- pynecore/core/instance_state.py +494 -0
- pynecore/core/live_ltf_collector.py +442 -0
- pynecore/core/live_ltf_window.py +189 -0
- pynecore/core/live_runner.py +1347 -0
- pynecore/core/module_property.py +26 -0
- pynecore/core/ohlcv_file.py +1888 -0
- pynecore/core/overload.py +371 -0
- pynecore/core/pine_cast.py +113 -0
- pynecore/core/pine_export.py +95 -0
- pynecore/core/pine_method.py +244 -0
- pynecore/core/pine_range.py +86 -0
- pynecore/core/pine_udt.py +69 -0
- pynecore/core/plugin/__init__.py +394 -0
- pynecore/core/plugin/broker.py +781 -0
- pynecore/core/plugin/cli.py +96 -0
- pynecore/core/plugin/live_provider.py +208 -0
- pynecore/core/plugin/provider.py +331 -0
- pynecore/core/provider_string.py +148 -0
- pynecore/core/random.py +40 -0
- pynecore/core/resampler.py +686 -0
- pynecore/core/safe_convert.py +64 -0
- pynecore/core/script.py +1011 -0
- pynecore/core/script_runner.py +3202 -0
- pynecore/core/security.py +1749 -0
- pynecore/core/security_process.py +1253 -0
- pynecore/core/security_shm.py +456 -0
- pynecore/core/series.py +417 -0
- pynecore/core/strategy_stats.py +669 -0
- pynecore/core/symbol_map.py +134 -0
- pynecore/core/syminfo.py +505 -0
- pynecore/core/viz.py +591 -0
- pynecore/lib/__init__.py +1771 -0
- pynecore/lib/_fixnan.py +32 -0
- pynecore/lib/_math_stateful.py +202 -0
- pynecore/lib/_timeframe_change.py +101 -0
- pynecore/lib/adjustment.py +6 -0
- pynecore/lib/alert.py +39 -0
- pynecore/lib/alert.pyi +14 -0
- pynecore/lib/array.py +1051 -0
- pynecore/lib/barmerge.py +60 -0
- pynecore/lib/barstate.py +30 -0
- pynecore/lib/box.py +415 -0
- pynecore/lib/chart.py +128 -0
- pynecore/lib/color.py +152 -0
- pynecore/lib/color.pyi +50 -0
- pynecore/lib/currency.py +62 -0
- pynecore/lib/dayofweek.py +36 -0
- pynecore/lib/dayofweek.pyi +18 -0
- pynecore/lib/display.py +8 -0
- pynecore/lib/dividends.py +9 -0
- pynecore/lib/earnings.py +11 -0
- pynecore/lib/extend.py +6 -0
- pynecore/lib/font.py +5 -0
- pynecore/lib/footprint.py +79 -0
- pynecore/lib/format.py +11 -0
- pynecore/lib/hline.py +67 -0
- pynecore/lib/hline.pyi +24 -0
- pynecore/lib/label.py +409 -0
- pynecore/lib/line.py +433 -0
- pynecore/lib/linefill.py +93 -0
- pynecore/lib/location.py +11 -0
- pynecore/lib/log.py +362 -0
- pynecore/lib/map.py +150 -0
- pynecore/lib/math.py +385 -0
- pynecore/lib/matrix.py +708 -0
- pynecore/lib/order.py +8 -0
- pynecore/lib/pivotpointtype.py +8 -0
- pynecore/lib/plot.py +95 -0
- pynecore/lib/plot.pyi +33 -0
- pynecore/lib/polyline.py +91 -0
- pynecore/lib/position.py +15 -0
- pynecore/lib/request.py +281 -0
- pynecore/lib/runtime.py +5 -0
- pynecore/lib/scale.py +9 -0
- pynecore/lib/session.py +267 -0
- pynecore/lib/session.pyi +12 -0
- pynecore/lib/shape.py +18 -0
- pynecore/lib/size.py +12 -0
- pynecore/lib/splits.py +4 -0
- pynecore/lib/strategy/__init__.py +4778 -0
- pynecore/lib/strategy/closedtrades.py +347 -0
- pynecore/lib/strategy/closedtrades.pyi +53 -0
- pynecore/lib/strategy/commission.py +9 -0
- pynecore/lib/strategy/direction.py +9 -0
- pynecore/lib/strategy/oca.py +13 -0
- pynecore/lib/strategy/opentrades.py +281 -0
- pynecore/lib/strategy/opentrades.pyi +49 -0
- pynecore/lib/strategy/risk.py +109 -0
- pynecore/lib/string.py +649 -0
- pynecore/lib/syminfo.py +84 -0
- pynecore/lib/ta.py +2230 -0
- pynecore/lib/table.py +290 -0
- pynecore/lib/text.py +17 -0
- pynecore/lib/ticker.py +207 -0
- pynecore/lib/timeframe.py +293 -0
- pynecore/lib/volume_row.py +67 -0
- pynecore/lib/xloc.py +4 -0
- pynecore/lib/yloc.py +5 -0
- pynecore/providers/__init__.py +0 -0
- pynecore/providers/ccxt.py +664 -0
- pynecore/providers/replay.py +187 -0
- pynecore/pynesys/__init__.py +0 -0
- pynecore/pynesys/api.py +498 -0
- pynecore/pynesys/compiler.py +112 -0
- pynecore/standalone.py +99 -0
- pynecore/testing/__init__.py +1 -0
- pynecore/testing/broker_lab/__init__.py +41 -0
- pynecore/testing/broker_lab/__main__.py +5 -0
- pynecore/testing/broker_lab/cli.py +87 -0
- pynecore/testing/broker_lab/generate.py +47 -0
- pynecore/testing/broker_lab/model.py +84 -0
- pynecore/testing/broker_lab/reference.py +645 -0
- pynecore/testing/broker_lab/runner.py +372 -0
- pynecore/testing/broker_lab/scheduler.py +50 -0
- pynecore/testing/broker_lab/subprocess.py +73 -0
- pynecore/transformers/__init__.py +0 -0
- pynecore/transformers/builtin_shadow.py +136 -0
- pynecore/transformers/closure_arguments_transformer.py +428 -0
- pynecore/transformers/display_rewrite.py +140 -0
- pynecore/transformers/dynamic_default.py +147 -0
- pynecore/transformers/function_isolation.py +757 -0
- pynecore/transformers/import_lifter.py +61 -0
- pynecore/transformers/import_normalizer.py +328 -0
- pynecore/transformers/inline_series_hoist.py +178 -0
- pynecore/transformers/input_transformer.py +175 -0
- pynecore/transformers/lib_series.py +201 -0
- pynecore/transformers/locations.py +70 -0
- pynecore/transformers/module_properties.json +3387 -0
- pynecore/transformers/module_property.py +221 -0
- pynecore/transformers/ne_guard.py +70 -0
- pynecore/transformers/persistent.py +320 -0
- pynecore/transformers/persistent_series.py +76 -0
- pynecore/transformers/safe_convert_transformer.py +97 -0
- pynecore/transformers/safe_division_transformer.py +95 -0
- pynecore/transformers/script_requirements.py +308 -0
- pynecore/transformers/security.py +752 -0
- pynecore/transformers/security_instantiation.py +274 -0
- pynecore/transformers/series.py +275 -0
- pynecore/transformers/slot_layout.py +381 -0
- pynecore/transformers/type_checking_stripper.py +25 -0
- pynecore/transformers/unused_series_detector.py +267 -0
- pynecore/types/__init__.py +21 -0
- pynecore/types/alert.py +5 -0
- pynecore/types/barmerge.py +5 -0
- pynecore/types/base.py +39 -0
- pynecore/types/box.py +37 -0
- pynecore/types/chart.py +17 -0
- pynecore/types/color.py +107 -0
- pynecore/types/currency.py +5 -0
- pynecore/types/datetime.py +6 -0
- pynecore/types/display.py +5 -0
- pynecore/types/dividends.py +5 -0
- pynecore/types/earnings.py +5 -0
- pynecore/types/extend.py +5 -0
- pynecore/types/font.py +5 -0
- pynecore/types/footprint.py +41 -0
- pynecore/types/format.py +5 -0
- pynecore/types/hline.py +24 -0
- pynecore/types/ib_persistent.py +8 -0
- pynecore/types/ib_persistent.pyi +10 -0
- pynecore/types/label.py +35 -0
- pynecore/types/line.py +32 -0
- pynecore/types/linefill.py +13 -0
- pynecore/types/location.py +5 -0
- pynecore/types/matrix.py +999 -0
- pynecore/types/na.py +237 -0
- pynecore/types/na.pyi +83 -0
- pynecore/types/ohlcv.py +12 -0
- pynecore/types/order.py +5 -0
- pynecore/types/persistent.py +8 -0
- pynecore/types/persistent.pyi +13 -0
- pynecore/types/pine_types.py +11 -0
- pynecore/types/pine_types.pyi +15 -0
- pynecore/types/pivotpointtype.py +5 -0
- pynecore/types/plot.py +12 -0
- pynecore/types/plot_meta.py +60 -0
- pynecore/types/polyline.py +40 -0
- pynecore/types/position.py +5 -0
- pynecore/types/scale.py +5 -0
- pynecore/types/script_type.py +15 -0
- pynecore/types/series.py +23 -0
- pynecore/types/series.pyi +19 -0
- pynecore/types/session.py +35 -0
- pynecore/types/shape.py +5 -0
- pynecore/types/size.py +5 -0
- pynecore/types/source.py +33 -0
- pynecore/types/splits.py +5 -0
- pynecore/types/strategy.py +45 -0
- pynecore/types/table.py +87 -0
- pynecore/types/text.py +13 -0
- pynecore/types/type_checker.py +7 -0
- pynecore/types/type_checker.pyi +48 -0
- pynecore/types/volume_row.py +36 -0
- pynecore/types/weekdays.py +11 -0
- pynecore/types/xloc.py +5 -0
- pynecore/types/yloc.py +5 -0
- pynecore/utils/__init__.py +0 -0
- pynecore/utils/file_utils.py +50 -0
- pynecore/utils/rich/__init__.py +0 -0
- pynecore/utils/rich/date_column.py +25 -0
- pynecore/utils/sequence_view.py +92 -0
- pynecore/utils/stdlib_checker.py +17 -0
|
@@ -0,0 +1,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
|