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,857 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from typing import TYPE_CHECKING, TypeAlias, cast
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from datetime import datetime, timedelta, UTC
|
|
6
|
+
|
|
7
|
+
from typer import Typer, Option, Argument, Exit, Context, secho, colors, confirm, BadParameter
|
|
8
|
+
|
|
9
|
+
from rich import print as rprint
|
|
10
|
+
from rich.progress import (Progress, SpinnerColumn, TextColumn, BarColumn,
|
|
11
|
+
TimeElapsedColumn, TimeRemainingColumn, TaskID)
|
|
12
|
+
|
|
13
|
+
from ..app import app, app_state
|
|
14
|
+
from ..pluggable import PluggableCommand
|
|
15
|
+
from ...core.plugin import discover_plugins, load_plugin, PluginNotFoundError
|
|
16
|
+
from ...core.plugin import ProviderPlugin, ProviderError
|
|
17
|
+
from ...core.provider_string import is_provider_string, parse_provider_string
|
|
18
|
+
from ...lib.timeframe import in_seconds
|
|
19
|
+
from ...core.data_converter import DataConverter, SupportedFormats as InputFormats
|
|
20
|
+
from ...core.download_runner import (download_to_file, ConflictAction, DownloadConflict,
|
|
21
|
+
DownloadPlan, DownloadProgress, DownloadError)
|
|
22
|
+
from ...core.ohlcv_file import OHLCVReader
|
|
23
|
+
from ...core.aggregator import validate_aggregation, aggregate_ohlcv
|
|
24
|
+
from ...core.syminfo import SymInfo
|
|
25
|
+
|
|
26
|
+
from ...utils.rich.date_column import DateColumn
|
|
27
|
+
|
|
28
|
+
__all__ = ['parse_date_or_days', 'validate_timeframe']
|
|
29
|
+
|
|
30
|
+
app_data = Typer(help="OHLCV related commands")
|
|
31
|
+
app.add_typer(app_data, name="data")
|
|
32
|
+
|
|
33
|
+
# Trick to avoid type checking errors
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
DateOrDays: TypeAlias = datetime
|
|
36
|
+
else:
|
|
37
|
+
# DateOrDays is either a datetime or a number of days
|
|
38
|
+
DateOrDays = str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _list_provider_names() -> list[str]:
|
|
42
|
+
"""Return the sorted names of all installed data-provider plugins."""
|
|
43
|
+
names = []
|
|
44
|
+
for name, ep in discover_plugins().items():
|
|
45
|
+
try:
|
|
46
|
+
cls = ep.load()
|
|
47
|
+
if isinstance(cls, type) and issubclass(cls, ProviderPlugin):
|
|
48
|
+
names.append(name)
|
|
49
|
+
except Exception: # noqa
|
|
50
|
+
pass
|
|
51
|
+
return sorted(names)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _choose_provider() -> str | None:
|
|
55
|
+
"""Open the installed-provider picker and return its selected entry point."""
|
|
56
|
+
from ..utils.provider_picker import ProviderChoice, ProviderPicker
|
|
57
|
+
|
|
58
|
+
providers: list[ProviderChoice] = []
|
|
59
|
+
for name, ep in discover_plugins().items():
|
|
60
|
+
try:
|
|
61
|
+
cls = ep.load()
|
|
62
|
+
if isinstance(cls, type) and issubclass(cls, ProviderPlugin):
|
|
63
|
+
display_name = getattr(cls, 'plugin_name', '')
|
|
64
|
+
providers.append(ProviderChoice(name, display_name))
|
|
65
|
+
except Exception: # noqa
|
|
66
|
+
pass
|
|
67
|
+
providers.sort(key=lambda item: item.id)
|
|
68
|
+
return ProviderPicker(providers).run()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _resolve_provider_class(provider_name: str) -> type[ProviderPlugin]:
|
|
72
|
+
"""
|
|
73
|
+
Load a data-provider plugin by name.
|
|
74
|
+
|
|
75
|
+
:param provider_name: Provider entry-point name (e.g. ``"ccxt"``).
|
|
76
|
+
:return: The provider plugin class.
|
|
77
|
+
:raises ValueError: If the name is unknown or refers to a non-provider
|
|
78
|
+
plugin. The caller's ``except (ImportError, ValueError)`` prints it.
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
cls = load_plugin(provider_name)
|
|
82
|
+
except PluginNotFoundError:
|
|
83
|
+
names = ', '.join(_list_provider_names()) or '(none)'
|
|
84
|
+
raise ValueError(f"Unknown provider '{provider_name}'. Available providers: {names}")
|
|
85
|
+
if not (isinstance(cls, type) and issubclass(cls, ProviderPlugin)):
|
|
86
|
+
raise ValueError(f"Plugin '{provider_name}' is not a data provider.")
|
|
87
|
+
return cast(type[ProviderPlugin], cls)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
# Available output formats
|
|
91
|
+
class OutputFormat(Enum):
|
|
92
|
+
CSV = 'csv'
|
|
93
|
+
JSON = 'json'
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _typer_validate_timeframe(value: str) -> str:
|
|
97
|
+
"""Typer callback wrapper: convert ValueError into a clean BadParameter."""
|
|
98
|
+
try:
|
|
99
|
+
return validate_timeframe(value)
|
|
100
|
+
except ValueError as e:
|
|
101
|
+
raise BadParameter(str(e))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _typer_parse_date_or_days(value: str) -> "datetime | str":
|
|
105
|
+
"""Typer callback wrapper: convert ValueError into a clean BadParameter."""
|
|
106
|
+
try:
|
|
107
|
+
return parse_date_or_days(value)
|
|
108
|
+
except ValueError as e:
|
|
109
|
+
raise BadParameter(str(e))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# TV-compatible timeframe validation function
|
|
113
|
+
def validate_timeframe(value: str) -> str:
|
|
114
|
+
"""
|
|
115
|
+
Validate TV-compatible timeframe string.
|
|
116
|
+
|
|
117
|
+
:param value: Timeframe string to validate
|
|
118
|
+
:return: Validated timeframe string
|
|
119
|
+
:raises ValueError: If timeframe is invalid
|
|
120
|
+
"""
|
|
121
|
+
value = value.upper()
|
|
122
|
+
try:
|
|
123
|
+
# Test if it's a valid TV timeframe by trying to convert to seconds
|
|
124
|
+
in_seconds(value)
|
|
125
|
+
except (ValueError, AssertionError):
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Invalid timeframe: {value}. Must be a valid timeframe in TradingView format "
|
|
128
|
+
f"(e.g. '1', '5', '60', '1D', '1W', '1M')."
|
|
129
|
+
)
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def parse_date_or_days(value: str) -> datetime | str:
|
|
134
|
+
"""
|
|
135
|
+
Parse a date, a number of days, ``"continue"``, or ``"now"``.
|
|
136
|
+
|
|
137
|
+
:param value: User-supplied string.
|
|
138
|
+
:return: A ``datetime`` (UTC, seconds/microseconds zeroed) or the ``"continue"`` sentinel.
|
|
139
|
+
:raises ValueError: If the value matches none of the accepted forms. Typer
|
|
140
|
+
converts this into a clean CLI error; in-process callers (e.g. the
|
|
141
|
+
symbol-browser wizard) can catch it directly.
|
|
142
|
+
"""
|
|
143
|
+
if value == 'continue':
|
|
144
|
+
return value
|
|
145
|
+
if not value:
|
|
146
|
+
return datetime.now(UTC).replace(second=0, microsecond=0)
|
|
147
|
+
if value.strip().lower() == 'now':
|
|
148
|
+
return datetime.now(UTC).replace(second=0, microsecond=0)
|
|
149
|
+
try:
|
|
150
|
+
return datetime.fromisoformat(str(value))
|
|
151
|
+
except ValueError:
|
|
152
|
+
pass
|
|
153
|
+
try:
|
|
154
|
+
days = int(value)
|
|
155
|
+
except ValueError:
|
|
156
|
+
raise ValueError(f"Invalid date format or days number: {value}")
|
|
157
|
+
if days < 0:
|
|
158
|
+
raise ValueError("Days cannot be negative")
|
|
159
|
+
return (datetime.now(UTC) - timedelta(days=days)).replace(second=0, microsecond=0)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _format_date_default(value, fallback: str) -> str:
|
|
163
|
+
"""Convert a parsed ``--from`` / ``--to`` value back into a user-friendly
|
|
164
|
+
string for the symbol-browser wizard's default fields.
|
|
165
|
+
|
|
166
|
+
``parse_date_or_days`` accepts the result as input, so the round-trip is
|
|
167
|
+
semantically loss-free.
|
|
168
|
+
"""
|
|
169
|
+
if isinstance(value, str):
|
|
170
|
+
return value
|
|
171
|
+
if isinstance(value, datetime):
|
|
172
|
+
ref = value if value.tzinfo else value.replace(tzinfo=UTC)
|
|
173
|
+
if abs((datetime.now(UTC) - ref).total_seconds()) < 60:
|
|
174
|
+
return "now"
|
|
175
|
+
return value.strftime("%Y-%m-%d %H:%M:%S")
|
|
176
|
+
return fallback
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@app_data.command(cls=PluggableCommand)
|
|
180
|
+
def download(
|
|
181
|
+
ctx: Context,
|
|
182
|
+
provider: str | None = Argument(None, show_default=False,
|
|
183
|
+
help="Provider name (e.g. 'ccxt'), a full provider string "
|
|
184
|
+
"(e.g. 'ccxt:BYBIT:BTC/USDT:USDT@1D'), or the path of an "
|
|
185
|
+
"already downloaded .ohlcv/.toml file to re-download it "
|
|
186
|
+
"with its saved provider string (typically with -f continue). "
|
|
187
|
+
"Omit it in a terminal to choose interactively"),
|
|
188
|
+
symbol: str | None = Option(None, '--symbol', '-s', show_default=False,
|
|
189
|
+
help="Symbol (e.g. BYBIT:BTC/USDT:USDT). Ignored when the "
|
|
190
|
+
"provider string already contains a symbol"),
|
|
191
|
+
list_symbols: bool = Option(False, '--list-symbols', '-ls',
|
|
192
|
+
help="List available symbols of the provider"),
|
|
193
|
+
list_brokers: bool = Option(False, '--list-brokers', '-lb',
|
|
194
|
+
help="List available brokers/exchanges of a multi-broker provider"),
|
|
195
|
+
timeframe: str = Option('1D', '--timeframe', '-tf', callback=_typer_validate_timeframe,
|
|
196
|
+
help="Timeframe in TradingView format (e.g., '1', '5S', '1D', '1W'). "
|
|
197
|
+
"Ignored when the provider string contains an @timeframe"),
|
|
198
|
+
time_from: DateOrDays = Option("continue", '--from', '-f',
|
|
199
|
+
callback=_typer_parse_date_or_days, formats=[],
|
|
200
|
+
metavar="[%Y-%m-%d|%Y-%m-%d %H:%M:%S|NUMBER]|continue",
|
|
201
|
+
help="Start date or days back from now, or 'continue' to resume last download,"
|
|
202
|
+
" or one year if no data"),
|
|
203
|
+
time_to: DateOrDays = Option(datetime.now(UTC).replace(second=0, microsecond=0), '--to', '-t',
|
|
204
|
+
callback=_typer_parse_date_or_days, formats=[],
|
|
205
|
+
metavar="[%Y-%m-%d|%Y-%m-%d %H:%M:%S|NUMBER]",
|
|
206
|
+
help="End date or days from start date"),
|
|
207
|
+
show_info: bool = Option(False, '--symbol-info', '-si', help="Show symbol info"),
|
|
208
|
+
force_save_info: bool = Option(False, '--force-save-info', '-fi',
|
|
209
|
+
help="Force save symbol info"),
|
|
210
|
+
truncate: bool = Option(False, '--truncate', '-tr',
|
|
211
|
+
help="Truncate file before downloading, all data will be lost"),
|
|
212
|
+
chunk_size: int | None = Option(None, '--chunk-size', '-cs',
|
|
213
|
+
help="Number of bars to download per API request. "
|
|
214
|
+
"Overrides automatic detection based on exchange limits. "
|
|
215
|
+
"Useful for exchanges with timeframe-specific limits (e.g., Bitget 1w: 12). "
|
|
216
|
+
"Lower values = slower but safer, higher values = faster but may hit API limits."),
|
|
217
|
+
extra_data: bool = Option(False, '--extra-data/--no-extra-data', '-ed',
|
|
218
|
+
help="Also download provider extra fields (ask/bid/spread) into a "
|
|
219
|
+
".extra.csv sidecar. Off by default: extra fields cost extra "
|
|
220
|
+
"requests and slow every later backtest that loads them. "
|
|
221
|
+
"Ignored by providers that have no extra fields."),
|
|
222
|
+
):
|
|
223
|
+
"""
|
|
224
|
+
Download historical OHLCV data
|
|
225
|
+
|
|
226
|
+
The provider can be given either as a bare name plus ``-s``/``-tf`` flags
|
|
227
|
+
(``pyne data download ccxt -s BYBIT:BTC/USDT:USDT -tf 1D``) or as a single
|
|
228
|
+
provider string in the same syntax as ``pyne run``
|
|
229
|
+
(``pyne data download ccxt:BYBIT:BTC/USDT:USDT@1D``). Both forms are equivalent.
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
from ...core.config import ensure_config
|
|
233
|
+
from ...core.download_info import read_download_provider
|
|
234
|
+
|
|
235
|
+
if provider is None:
|
|
236
|
+
if not sys.stdin.isatty():
|
|
237
|
+
secho("Error: Provider is required in non-interactive mode.",
|
|
238
|
+
err=True, fg=colors.RED)
|
|
239
|
+
raise Exit(2)
|
|
240
|
+
provider = _choose_provider()
|
|
241
|
+
if provider is None:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
# A .ohlcv/.toml path re-downloads with the provider string persisted
|
|
245
|
+
# in the [download] section of its syminfo TOML.
|
|
246
|
+
if provider.endswith(('.ohlcv', '.toml')):
|
|
247
|
+
given = Path(provider)
|
|
248
|
+
path = next((p for p in (given, app_state.data_dir / given.name) if p.exists()), None)
|
|
249
|
+
if path is None:
|
|
250
|
+
secho(f"Error: File not found: {provider}", err=True, fg=colors.RED)
|
|
251
|
+
raise Exit(1)
|
|
252
|
+
saved = read_download_provider(path.with_suffix('.toml'))
|
|
253
|
+
if saved is None:
|
|
254
|
+
secho(f"Error: {path.with_suffix('.toml').name} has no [download] provider "
|
|
255
|
+
f"section. Download it once with a provider string to record it.",
|
|
256
|
+
err=True, fg=colors.RED)
|
|
257
|
+
raise Exit(1)
|
|
258
|
+
provider = saved
|
|
259
|
+
|
|
260
|
+
# Resolve the provider plugin from either the bare name or the leading
|
|
261
|
+
# segment of a provider string.
|
|
262
|
+
string_mode = is_provider_string(provider)
|
|
263
|
+
provider_name = (provider.split(':', 1)[0] if string_mode else provider).lower()
|
|
264
|
+
provider_class = _resolve_provider_class(provider_name)
|
|
265
|
+
|
|
266
|
+
# --list-brokers needs only the provider plugin
|
|
267
|
+
if list_brokers:
|
|
268
|
+
try:
|
|
269
|
+
brokers = provider_class.get_list_of_brokers()
|
|
270
|
+
except NotImplementedError:
|
|
271
|
+
secho(f"Provider '{provider_name}' does not support listing brokers.",
|
|
272
|
+
err=True, fg=colors.RED)
|
|
273
|
+
raise Exit(1)
|
|
274
|
+
except ProviderError as e:
|
|
275
|
+
secho(f"Error: {e}", err=True, fg=colors.RED)
|
|
276
|
+
raise Exit(1)
|
|
277
|
+
ordered = sorted(brokers)
|
|
278
|
+
id_width = max((len(b.id) for b in ordered), default=0)
|
|
279
|
+
for b in ordered:
|
|
280
|
+
print(f"{b.id:<{id_width}} {b.name}".rstrip() if b.name else b.id)
|
|
281
|
+
return
|
|
282
|
+
|
|
283
|
+
# In string mode, derive broker/symbol/timeframe from the provider
|
|
284
|
+
# string; the broker is re-folded into the symbol so multi-broker
|
|
285
|
+
# providers (which split it off internally) receive what they expect.
|
|
286
|
+
if string_mode:
|
|
287
|
+
ps = parse_provider_string(provider, multi_broker=provider_class.multi_broker)
|
|
288
|
+
if ps.symbol and symbol:
|
|
289
|
+
raise ValueError("Symbol given both in the provider string and via "
|
|
290
|
+
"-s/--symbol; use only one.")
|
|
291
|
+
resolved_symbol = ps.symbol or symbol
|
|
292
|
+
if ps.timeframe is not None:
|
|
293
|
+
timeframe = validate_timeframe(ps.timeframe)
|
|
294
|
+
if ps.broker:
|
|
295
|
+
symbol = f"{ps.broker}:{resolved_symbol}" if resolved_symbol else ps.broker
|
|
296
|
+
else:
|
|
297
|
+
symbol = resolved_symbol
|
|
298
|
+
|
|
299
|
+
config = None
|
|
300
|
+
config_cls: type | None = getattr(provider_class, 'Config', None)
|
|
301
|
+
if config_cls is not None:
|
|
302
|
+
config = ensure_config(config_cls,
|
|
303
|
+
app_state.config_dir / 'plugins' / f'{provider_name}.toml')
|
|
304
|
+
|
|
305
|
+
def _fetch_symbols(sym: str | None) -> "tuple[ProviderPlugin, list[str], Path | None]":
|
|
306
|
+
"""Construct the provider for ``sym`` and, when it is a selector-only
|
|
307
|
+
value (the provider leaves ``self.symbol`` as ``None``), fetch its
|
|
308
|
+
symbol list for the TUI. Shown behind a spinner.
|
|
309
|
+
|
|
310
|
+
:return: ``(provider_instance, symbol_list, tui_ohlcv_dir)``.
|
|
311
|
+
"""
|
|
312
|
+
slist: list[str] = []
|
|
313
|
+
tdir: Path | None = None
|
|
314
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"),
|
|
315
|
+
transient=True) as progress:
|
|
316
|
+
progress.add_task(description="Fetching market data...", total=None)
|
|
317
|
+
if sym is None:
|
|
318
|
+
inst: ProviderPlugin = provider_class(symbol=None, timeframe=timeframe,
|
|
319
|
+
config=config)
|
|
320
|
+
tdir = app_state.data_dir
|
|
321
|
+
else:
|
|
322
|
+
inst = provider_class(symbol=sym, timeframe=timeframe,
|
|
323
|
+
ohlcv_dir=app_state.data_dir, config=config)
|
|
324
|
+
if inst.symbol is None and sys.stdin.isatty():
|
|
325
|
+
slist = inst.get_list_of_symbols()
|
|
326
|
+
tdir = app_state.data_dir
|
|
327
|
+
return inst, slist, tdir
|
|
328
|
+
|
|
329
|
+
def _browse_symbols(inst: "ProviderPlugin", slist: list[str], tdir: Path,
|
|
330
|
+
*, selector: str | None, can_go_back: bool) -> bool:
|
|
331
|
+
"""Launch the symbol browser TUI for ``inst``.
|
|
332
|
+
|
|
333
|
+
:param selector: Broker/exchange selector the provider was opened
|
|
334
|
+
with (the browsed symbols carry no such prefix), or None for
|
|
335
|
+
single-broker providers. Used to build the canonical provider
|
|
336
|
+
string persisted next to the download.
|
|
337
|
+
:param can_go_back: When True, ESC returns to the broker picker
|
|
338
|
+
instead of quitting the command.
|
|
339
|
+
:return: True if the user asked to go back to the broker list.
|
|
340
|
+
"""
|
|
341
|
+
from ..utils.symbol_browser import SymbolBrowser
|
|
342
|
+
prefix = f"{provider_name}:{selector}" if selector else provider_name
|
|
343
|
+
browser = SymbolBrowser(
|
|
344
|
+
inst,
|
|
345
|
+
slist,
|
|
346
|
+
ohlcv_dir=tdir,
|
|
347
|
+
provider_string_prefix=prefix,
|
|
348
|
+
default_timeframe=timeframe,
|
|
349
|
+
default_from=_format_date_default(time_from, "continue"),
|
|
350
|
+
default_to=_format_date_default(time_to, "now"),
|
|
351
|
+
default_chunk_size=chunk_size,
|
|
352
|
+
default_extra_data=extra_data,
|
|
353
|
+
can_go_back=can_go_back,
|
|
354
|
+
)
|
|
355
|
+
browser.run()
|
|
356
|
+
return browser.go_back
|
|
357
|
+
|
|
358
|
+
provider_instance: ProviderPlugin | None = None
|
|
359
|
+
symbols_list: list[str] = []
|
|
360
|
+
tui_ohlcv_dir: Path | None = None
|
|
361
|
+
|
|
362
|
+
# Multi-broker providers (CCXT, cTrader) need a broker chosen before a
|
|
363
|
+
# symbol can be browsed. When none was given and we're interactive, drop
|
|
364
|
+
# into a broker picker first, then the symbol browser. The two form a
|
|
365
|
+
# loop: ESC in the symbol browser returns to the broker list rather than
|
|
366
|
+
# exiting, and a failure opening the chosen broker (e.g. an exchange that
|
|
367
|
+
# needs API credentials) returns to the picker with the error shown so
|
|
368
|
+
# another broker can be tried. Only the picker itself exits the command.
|
|
369
|
+
if (provider_class.multi_broker and symbol is None
|
|
370
|
+
and not list_symbols and sys.stdin.isatty()):
|
|
371
|
+
try:
|
|
372
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"),
|
|
373
|
+
transient=True) as progress:
|
|
374
|
+
progress.add_task(description="Fetching available brokers...", total=None)
|
|
375
|
+
brokers = provider_class.get_list_of_brokers()
|
|
376
|
+
except (NotImplementedError, ProviderError) as e:
|
|
377
|
+
secho(f"Error: {e}", err=True, fg=colors.RED)
|
|
378
|
+
raise Exit(1)
|
|
379
|
+
from ..utils.broker_picker import BrokerPicker
|
|
380
|
+
display_name = getattr(provider_class, 'plugin_name', provider_name)
|
|
381
|
+
picker = BrokerPicker(sorted(brokers), provider_name=display_name)
|
|
382
|
+
while True:
|
|
383
|
+
chosen = picker.run()
|
|
384
|
+
if chosen is None:
|
|
385
|
+
return
|
|
386
|
+
try:
|
|
387
|
+
provider_instance, symbols_list, tui_ohlcv_dir = _fetch_symbols(chosen)
|
|
388
|
+
except (NotImplementedError, ProviderError) as e:
|
|
389
|
+
picker.error = str(e)
|
|
390
|
+
continue
|
|
391
|
+
picker.error = None
|
|
392
|
+
assert tui_ohlcv_dir is not None
|
|
393
|
+
if _browse_symbols(provider_instance, symbols_list, tui_ohlcv_dir,
|
|
394
|
+
selector=chosen, can_go_back=True):
|
|
395
|
+
continue
|
|
396
|
+
return
|
|
397
|
+
|
|
398
|
+
# If list_symbols is True, we show the available symbols then exit
|
|
399
|
+
if list_symbols:
|
|
400
|
+
try:
|
|
401
|
+
with Progress(SpinnerColumn(), TextColumn("{task.description}"),
|
|
402
|
+
transient=True) as progress:
|
|
403
|
+
progress.add_task(description="Fetching market data...", total=None)
|
|
404
|
+
provider_instance = provider_class(symbol=symbol, config=config)
|
|
405
|
+
symbols = provider_instance.get_list_of_symbols()
|
|
406
|
+
except (NotImplementedError, ProviderError) as e:
|
|
407
|
+
secho(f"Error: {e}", err=True, fg=colors.RED)
|
|
408
|
+
raise Exit(1)
|
|
409
|
+
for s in symbols:
|
|
410
|
+
print(s)
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
# Some providers (e.g. CCXT) accept a selector-only ``--symbol`` (just
|
|
414
|
+
# the exchange name): the constructor recognises it and leaves
|
|
415
|
+
# ``self.symbol`` as ``None``. That signals "we know which backend but
|
|
416
|
+
# not which instrument yet" — drop into the TUI to let the user pick.
|
|
417
|
+
# Otherwise proceed to the download path.
|
|
418
|
+
try:
|
|
419
|
+
provider_instance, symbols_list, tui_ohlcv_dir = _fetch_symbols(symbol)
|
|
420
|
+
except NotImplementedError as e:
|
|
421
|
+
secho(f"Error: {e}", err=True, fg=colors.RED)
|
|
422
|
+
secho("Pass a symbol explicitly with -s/--symbol.",
|
|
423
|
+
err=True, fg=colors.YELLOW)
|
|
424
|
+
raise Exit(1)
|
|
425
|
+
except ProviderError as e:
|
|
426
|
+
secho(f"Error: {e}", err=True, fg=colors.RED)
|
|
427
|
+
raise Exit(1)
|
|
428
|
+
|
|
429
|
+
if provider_instance.symbol is None:
|
|
430
|
+
if not sys.stdin.isatty():
|
|
431
|
+
secho("Error: Symbol is required "
|
|
432
|
+
"(or use --list-symbols for non-interactive listing).",
|
|
433
|
+
err=True, fg=colors.RED)
|
|
434
|
+
raise Exit(1)
|
|
435
|
+
assert tui_ohlcv_dir is not None
|
|
436
|
+
_browse_symbols(provider_instance, symbols_list, tui_ohlcv_dir,
|
|
437
|
+
selector=symbol, can_go_back=False)
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
# Download symbol info if not exists
|
|
441
|
+
if force_save_info or not provider_instance.is_symbol_info_exists():
|
|
442
|
+
with Progress(SpinnerColumn(finished_text="[green]✓"), TextColumn("{task.description}")) as progress:
|
|
443
|
+
# Get symbol info task
|
|
444
|
+
task = progress.add_task(description="Fetching symbol info...", total=1)
|
|
445
|
+
sym_info = provider_instance.get_symbol_info(force_update=force_save_info)
|
|
446
|
+
|
|
447
|
+
# Complete task
|
|
448
|
+
progress.update(task, completed=1)
|
|
449
|
+
|
|
450
|
+
# Print symbol info
|
|
451
|
+
if show_info:
|
|
452
|
+
rprint(sym_info)
|
|
453
|
+
else: # We have symbol info, just show it
|
|
454
|
+
sym_info = provider_instance.get_symbol_info()
|
|
455
|
+
if show_info:
|
|
456
|
+
rprint(sym_info)
|
|
457
|
+
|
|
458
|
+
# Hand any plugin-injected CLI flags to the provider; it reads only its
|
|
459
|
+
# own keys (see CLIPlugin.cli_params / PluggableCommand).
|
|
460
|
+
provider_instance.plugin_params = getattr(ctx, "plugin_params", {})
|
|
461
|
+
|
|
462
|
+
# Progress display is created only once the download range is resolved
|
|
463
|
+
# (the bar's date column and total both need it).
|
|
464
|
+
dl_progress: Progress | None = None
|
|
465
|
+
dl_task: TaskID | None = None
|
|
466
|
+
|
|
467
|
+
def on_start(plan: DownloadPlan) -> None:
|
|
468
|
+
""" Open the progress display matching the resolved range """
|
|
469
|
+
nonlocal dl_progress, dl_task
|
|
470
|
+
if plan.fetch_all:
|
|
471
|
+
# fetch_all provider: use spinner-only progress (no time-based progress bar)
|
|
472
|
+
display = Progress(
|
|
473
|
+
SpinnerColumn(finished_text="[green]✓"),
|
|
474
|
+
TextColumn("{task.description}"),
|
|
475
|
+
TimeElapsedColumn(),
|
|
476
|
+
)
|
|
477
|
+
display.start()
|
|
478
|
+
dl_task = display.add_task(
|
|
479
|
+
description="Downloading all available OHLCV data...",
|
|
480
|
+
total=None,
|
|
481
|
+
)
|
|
482
|
+
else:
|
|
483
|
+
display = Progress(
|
|
484
|
+
SpinnerColumn(finished_text="[green]✓"),
|
|
485
|
+
TextColumn("{task.description}"),
|
|
486
|
+
DateColumn(plan.time_from),
|
|
487
|
+
BarColumn(),
|
|
488
|
+
TimeElapsedColumn(),
|
|
489
|
+
"/",
|
|
490
|
+
TimeRemainingColumn(),
|
|
491
|
+
)
|
|
492
|
+
display.start()
|
|
493
|
+
dl_task = display.add_task(
|
|
494
|
+
description="Downloading OHLCV data...",
|
|
495
|
+
total=plan.total_seconds,
|
|
496
|
+
)
|
|
497
|
+
dl_progress = display
|
|
498
|
+
|
|
499
|
+
def on_progress(progress_info: DownloadProgress) -> None:
|
|
500
|
+
""" Callback to update progress """
|
|
501
|
+
assert dl_progress is not None and dl_task is not None
|
|
502
|
+
dl_progress.update(dl_task, completed=progress_info.elapsed_seconds)
|
|
503
|
+
|
|
504
|
+
def on_conflict(conflict: DownloadConflict) -> ConflictAction:
|
|
505
|
+
""" Ask the user before an existing file gets truncated """
|
|
506
|
+
secho(f"The start date (from: {conflict.time_from}) is before the start of the "
|
|
507
|
+
f"existing file ({conflict.existing_start}).\n"
|
|
508
|
+
f"If you continue, the file will be truncated.",
|
|
509
|
+
fg=colors.YELLOW)
|
|
510
|
+
confirm("Do you want to continue?", abort=True)
|
|
511
|
+
return 'truncate'
|
|
512
|
+
|
|
513
|
+
try:
|
|
514
|
+
assert provider_instance is not None
|
|
515
|
+
download_to_file(
|
|
516
|
+
provider_instance,
|
|
517
|
+
time_from=time_from, time_to=time_to,
|
|
518
|
+
truncate=truncate, chunk_size=chunk_size, extra_data=extra_data,
|
|
519
|
+
syminfo=sym_info,
|
|
520
|
+
on_start=on_start, on_progress=on_progress, on_conflict=on_conflict,
|
|
521
|
+
provider_string=f"{provider_name}:{symbol}@{timeframe}",
|
|
522
|
+
)
|
|
523
|
+
except DownloadError as e:
|
|
524
|
+
secho(f"Error: {e}", err=True, fg=colors.RED)
|
|
525
|
+
raise Exit(1)
|
|
526
|
+
finally:
|
|
527
|
+
if dl_progress is not None:
|
|
528
|
+
dl_progress.stop()
|
|
529
|
+
|
|
530
|
+
except ProviderError as e:
|
|
531
|
+
secho(f"Error: {e}", err=True, fg=colors.RED)
|
|
532
|
+
raise Exit(1)
|
|
533
|
+
except (ImportError, ValueError) as e:
|
|
534
|
+
secho(str(e), err=True, fg=colors.RED)
|
|
535
|
+
raise Exit(2)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@app_data.command()
|
|
539
|
+
def convert_to(
|
|
540
|
+
ohlcv_path: Path = Argument(..., dir_okay=False, file_okay=True,
|
|
541
|
+
help="Data file to convert (*.ohlcv)"),
|
|
542
|
+
fmt: OutputFormat = Option(
|
|
543
|
+
'csv', '--format', '-f',
|
|
544
|
+
case_sensitive=False,
|
|
545
|
+
help="Output format"),
|
|
546
|
+
as_datetime: bool = Option(False, '--as-datetime', '-dt',
|
|
547
|
+
help="Save timestamp as datetime instead of UNIX timestamp"),
|
|
548
|
+
):
|
|
549
|
+
"""
|
|
550
|
+
Convert downloaded data from pyne's OHLCV format to another format
|
|
551
|
+
"""
|
|
552
|
+
# Check file format and extension
|
|
553
|
+
if ohlcv_path.suffix == "":
|
|
554
|
+
# No extension, add .ohlcv
|
|
555
|
+
ohlcv_path = ohlcv_path.with_suffix(".ohlcv")
|
|
556
|
+
|
|
557
|
+
# Expand data path
|
|
558
|
+
if len(ohlcv_path.parts) == 1:
|
|
559
|
+
ohlcv_path = app_state.data_dir / ohlcv_path
|
|
560
|
+
# Check if data exists
|
|
561
|
+
if not ohlcv_path.exists():
|
|
562
|
+
secho(f"Data file '{ohlcv_path}' not found!", fg="red", err=True)
|
|
563
|
+
raise Exit(1)
|
|
564
|
+
|
|
565
|
+
out_path = None
|
|
566
|
+
with Progress(SpinnerColumn(finished_text="[green]✓"), TextColumn("{task.description}")) as progress:
|
|
567
|
+
# Convert
|
|
568
|
+
with OHLCVReader(str(ohlcv_path)) as ohlcv_reader:
|
|
569
|
+
if fmt.value == OutputFormat.CSV.value:
|
|
570
|
+
task = progress.add_task(description="Converting to CSV...", total=1)
|
|
571
|
+
out_path = str(ohlcv_path.with_suffix('.csv'))
|
|
572
|
+
ohlcv_reader.save_to_csv(out_path, as_datetime=as_datetime)
|
|
573
|
+
|
|
574
|
+
elif fmt.value == OutputFormat.JSON.value:
|
|
575
|
+
task = progress.add_task(description="Converting to JSON...", total=1)
|
|
576
|
+
out_path = str(ohlcv_path.with_suffix('.json'))
|
|
577
|
+
ohlcv_reader.save_to_json(out_path, as_datetime=as_datetime)
|
|
578
|
+
|
|
579
|
+
else:
|
|
580
|
+
raise ValueError(f"Unsupported format: {fmt}")
|
|
581
|
+
|
|
582
|
+
# Complete task
|
|
583
|
+
progress.update(task, completed=1)
|
|
584
|
+
|
|
585
|
+
if out_path:
|
|
586
|
+
secho(f'Data file converted successfully to "{out_path}"!')
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
@app_data.command()
|
|
590
|
+
def convert_from(
|
|
591
|
+
file_path: Path = Argument(..., help="Path to CSV/JSON/TXT file to convert"),
|
|
592
|
+
provider: str = Option(None, '--provider', '-p',
|
|
593
|
+
help="Data provider, can be any name"),
|
|
594
|
+
symbol: str | None = Option(None, '--symbol', '-s', show_default=False,
|
|
595
|
+
help="Symbol (default: from file name)"),
|
|
596
|
+
tz: str = Option('UTC', '--timezone', '-tz', help="Timezone"),
|
|
597
|
+
):
|
|
598
|
+
"""
|
|
599
|
+
Convert data from other sources to pyne's OHLCV format
|
|
600
|
+
"""
|
|
601
|
+
# Expand file path if only filename is provided (look in workdir/data)
|
|
602
|
+
if len(file_path.parts) == 1:
|
|
603
|
+
file_path = app_state.data_dir / file_path
|
|
604
|
+
|
|
605
|
+
# Check if file exists
|
|
606
|
+
if not file_path.exists():
|
|
607
|
+
secho(f'File "{file_path}" not found!', fg=colors.RED, err=True)
|
|
608
|
+
raise Exit(1)
|
|
609
|
+
|
|
610
|
+
# Auto-detect symbol and provider from filename if not provided
|
|
611
|
+
detected_symbol, detected_provider = DataConverter.guess_symbol_from_filename(file_path)
|
|
612
|
+
|
|
613
|
+
if symbol is None:
|
|
614
|
+
symbol = detected_symbol
|
|
615
|
+
|
|
616
|
+
if provider is None and detected_provider is not None:
|
|
617
|
+
provider = detected_provider
|
|
618
|
+
|
|
619
|
+
# Fallback: peek into CSV content (Databento exports carry the symbol as a column)
|
|
620
|
+
if (symbol is None or provider is None) and file_path.suffix.lower() == '.csv':
|
|
621
|
+
content_symbol, content_provider = DataConverter.guess_symbol_from_csv_content(file_path)
|
|
622
|
+
if symbol is None and content_symbol:
|
|
623
|
+
symbol = content_symbol
|
|
624
|
+
if provider is None and content_provider:
|
|
625
|
+
provider = content_provider
|
|
626
|
+
|
|
627
|
+
# Ensure we have required parameters
|
|
628
|
+
if symbol is None:
|
|
629
|
+
secho(f"Error: Could not detect symbol from filename '{file_path.name}'!", fg=colors.RED, err=True)
|
|
630
|
+
secho("Please provide a symbol using --symbol option.", fg=colors.YELLOW, err=True)
|
|
631
|
+
raise Exit(1)
|
|
632
|
+
|
|
633
|
+
# Auto-detect file format
|
|
634
|
+
fmt = file_path.suffix[1:].lower()
|
|
635
|
+
if fmt not in InputFormats:
|
|
636
|
+
raise ValueError(f"Unsupported file format: {file_path}")
|
|
637
|
+
|
|
638
|
+
# Use the enhanced DataConverter for automatic conversion
|
|
639
|
+
converter = DataConverter()
|
|
640
|
+
|
|
641
|
+
try:
|
|
642
|
+
with Progress(SpinnerColumn(finished_text="[green]✓"), TextColumn("{task.description}")) as progress:
|
|
643
|
+
task = progress.add_task(description=f"Converting {fmt.upper()} to OHLCV format...", total=1)
|
|
644
|
+
|
|
645
|
+
# Perform conversion with automatic TOML generation
|
|
646
|
+
converter.convert_to_ohlcv(
|
|
647
|
+
file_path=Path(file_path),
|
|
648
|
+
provider=provider,
|
|
649
|
+
symbol=symbol,
|
|
650
|
+
timezone=tz,
|
|
651
|
+
force=True
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
progress.update(task, completed=1)
|
|
655
|
+
|
|
656
|
+
except Exception as e:
|
|
657
|
+
secho(f"Error: {e}", err=True, fg=colors.RED)
|
|
658
|
+
raise Exit(1)
|
|
659
|
+
|
|
660
|
+
secho(f'Data file converted successfully to "{file_path}".')
|
|
661
|
+
secho(f'A configuration file was automatically generated for you at "{file_path.with_suffix(".toml")}". '
|
|
662
|
+
f'Please check it and adjust it to match your needs.')
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@app_data.command()
|
|
666
|
+
def aggregate(
|
|
667
|
+
source: Path = Argument(..., help="Source .ohlcv file (searches in workdir/data/ if only name given)"),
|
|
668
|
+
timeframe: str = Option(..., '--timeframe', '-tf', callback=_typer_validate_timeframe,
|
|
669
|
+
help="Target timeframe in TradingView format (e.g., '60', '1D', '1W')"),
|
|
670
|
+
output: Path | None = Option(None, '--output', '-o',
|
|
671
|
+
help="Custom output path (auto-generated if not specified)"),
|
|
672
|
+
):
|
|
673
|
+
"""
|
|
674
|
+
Aggregate OHLCV data from a lower timeframe to a higher one.
|
|
675
|
+
|
|
676
|
+
Combines multiple smaller candles into larger timeframe candles.
|
|
677
|
+
For example: daily candles → weekly candles, or 5-minute → 1-hour.
|
|
678
|
+
|
|
679
|
+
The source timeframe is read from the .toml metadata file.
|
|
680
|
+
Only upscaling is supported (small → large timeframe).
|
|
681
|
+
"""
|
|
682
|
+
# Resolve source path
|
|
683
|
+
if len(source.parts) == 1:
|
|
684
|
+
source = app_state.data_dir / source
|
|
685
|
+
if source.suffix == "":
|
|
686
|
+
source = source.with_suffix(".ohlcv")
|
|
687
|
+
|
|
688
|
+
if not source.exists():
|
|
689
|
+
secho(f"Error: Source file not found: {source}", err=True, fg=colors.RED)
|
|
690
|
+
raise Exit(1)
|
|
691
|
+
|
|
692
|
+
if source.suffix != '.ohlcv':
|
|
693
|
+
secho(f"Error: Source must be .ohlcv format, got: {source.suffix}", err=True, fg=colors.RED)
|
|
694
|
+
raise Exit(1)
|
|
695
|
+
|
|
696
|
+
# Read source timeframe from TOML
|
|
697
|
+
toml_path = source.with_suffix('.toml')
|
|
698
|
+
if not toml_path.exists():
|
|
699
|
+
secho(f"Error: Metadata file not found: {toml_path}", err=True, fg=colors.RED)
|
|
700
|
+
raise Exit(1)
|
|
701
|
+
|
|
702
|
+
try:
|
|
703
|
+
syminfo = SymInfo.load_toml(toml_path)
|
|
704
|
+
except Exception as e:
|
|
705
|
+
secho(f"Error reading metadata: {e}", err=True, fg=colors.RED)
|
|
706
|
+
raise Exit(1)
|
|
707
|
+
|
|
708
|
+
source_tf = syminfo.period
|
|
709
|
+
|
|
710
|
+
# Validate timeframe compatibility
|
|
711
|
+
try:
|
|
712
|
+
validate_aggregation(source_tf, timeframe)
|
|
713
|
+
except ValueError as e:
|
|
714
|
+
secho(f"Error: {e}", err=True, fg=colors.RED)
|
|
715
|
+
raise Exit(1)
|
|
716
|
+
|
|
717
|
+
# Generate output path if not specified
|
|
718
|
+
if output is None:
|
|
719
|
+
# Replace the timeframe suffix in the filename: symbol_1D.ohlcv → symbol_1W.ohlcv
|
|
720
|
+
stem = source.stem
|
|
721
|
+
# If the stem ends with the source timeframe, replace it
|
|
722
|
+
if stem.endswith(f"_{source_tf}"):
|
|
723
|
+
new_stem = stem[:-len(source_tf)] + timeframe
|
|
724
|
+
else:
|
|
725
|
+
new_stem = f"{stem}_{timeframe}"
|
|
726
|
+
out_path: Path = source.parent / f"{new_stem}.ohlcv"
|
|
727
|
+
else:
|
|
728
|
+
out_path = output
|
|
729
|
+
|
|
730
|
+
if len(out_path.parts) == 1:
|
|
731
|
+
out_path = app_state.data_dir / out_path
|
|
732
|
+
|
|
733
|
+
if out_path.suffix == "":
|
|
734
|
+
out_path = out_path.with_suffix(".ohlcv")
|
|
735
|
+
|
|
736
|
+
# Confirm before overwriting existing file
|
|
737
|
+
if out_path.exists():
|
|
738
|
+
secho(f"Target file already exists: {out_path.name}", fg=colors.YELLOW)
|
|
739
|
+
confirm("Overwrite?", abort=True)
|
|
740
|
+
|
|
741
|
+
# Perform aggregation
|
|
742
|
+
with Progress(
|
|
743
|
+
SpinnerColumn(finished_text="[green]✓"),
|
|
744
|
+
TextColumn("{task.description}"),
|
|
745
|
+
) as progress:
|
|
746
|
+
progress.add_task(
|
|
747
|
+
description=f"Aggregating {source_tf} → {timeframe}...",
|
|
748
|
+
total=None,
|
|
749
|
+
)
|
|
750
|
+
|
|
751
|
+
try:
|
|
752
|
+
# Use data timezone from TOML for correct day/week/month boundaries,
|
|
753
|
+
# and the session opens so intraday bars anchor to the session the
|
|
754
|
+
# way TradingView does (no-op for on-hour / 24-7 markets). The
|
|
755
|
+
# symbol type and opening hours drive the multi-period (nD/nW/nM)
|
|
756
|
+
# scheduled grid (see the resampler module docs).
|
|
757
|
+
from zoneinfo import ZoneInfo
|
|
758
|
+
data_tz = ZoneInfo(syminfo.timezone) if syminfo.timezone else None
|
|
759
|
+
source_count, target_count = aggregate_ohlcv(
|
|
760
|
+
source, out_path, timeframe, tz=data_tz,
|
|
761
|
+
session_starts=syminfo.session_starts,
|
|
762
|
+
opening_hours=syminfo.opening_hours,
|
|
763
|
+
sym_type=syminfo.type, source_tf=source_tf)
|
|
764
|
+
except Exception as e:
|
|
765
|
+
secho(f"Error during aggregation: {e}", err=True, fg=colors.RED)
|
|
766
|
+
raise Exit(1)
|
|
767
|
+
|
|
768
|
+
# Copy and update TOML for the target file
|
|
769
|
+
target_toml = out_path.with_suffix('.toml')
|
|
770
|
+
try:
|
|
771
|
+
syminfo.period = timeframe
|
|
772
|
+
syminfo.save_toml(target_toml)
|
|
773
|
+
except Exception as e:
|
|
774
|
+
secho(f"Warning: Could not write metadata: {e}", fg=colors.YELLOW)
|
|
775
|
+
|
|
776
|
+
secho(f"Aggregated {source_count:,} → {target_count:,} candles ({source_tf} → {timeframe})")
|
|
777
|
+
secho(f'Output: "{out_path}"')
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
_SYMBOL_MAP_HEADER = """\
|
|
781
|
+
# Global symbol map: TradingView-style script symbols -> provider-qualified
|
|
782
|
+
# native symbols. The value format matches the [download] provider string
|
|
783
|
+
# ("provider:BROKER:SYMBOL"). An optional ":TF" suffix on the KEY overrides a
|
|
784
|
+
# single timeframe. One entry serves both backtest (the .ohlcv file is derived
|
|
785
|
+
# via the provider's naming convention) and live (a native PluginSymbol).
|
|
786
|
+
#
|
|
787
|
+
# Example:
|
|
788
|
+
# [symbol_map]
|
|
789
|
+
# "BINANCE:BTCUSDT" = "ccxt:BYBIT:BTC/USDT:USDT"
|
|
790
|
+
# "NASDAQ:AAPL" = "capitalcom:AAPL"
|
|
791
|
+
# "NASDAQ:AAPL:60" = "capitalcom:AAPL"
|
|
792
|
+
"""
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
@app_data.command(name="map")
|
|
796
|
+
def map_symbol(
|
|
797
|
+
tv_symbol: str = Argument(...,
|
|
798
|
+
help="TradingView-style symbol key, e.g. 'NASDAQ:AAPL' "
|
|
799
|
+
"or 'NASDAQ:AAPL:60' for a per-timeframe override"),
|
|
800
|
+
provider_symbol: str = Argument(...,
|
|
801
|
+
help="Provider-qualified native symbol, e.g. "
|
|
802
|
+
"'capitalcom:AAPL' or 'ccxt:BYBIT:BTC/USDT:USDT'"),
|
|
803
|
+
):
|
|
804
|
+
"""
|
|
805
|
+
Add or update a global symbol_map.toml entry.
|
|
806
|
+
|
|
807
|
+
Writes <workdir>/config/symbol_map.toml, creating it with a documented
|
|
808
|
+
header when absent and preserving existing entries and comments otherwise.
|
|
809
|
+
"""
|
|
810
|
+
from ...core.symbol_map import MappedSymbol, SYMBOL_MAP_FILENAME
|
|
811
|
+
|
|
812
|
+
mapped = MappedSymbol.parse(provider_symbol)
|
|
813
|
+
if mapped is None:
|
|
814
|
+
secho(
|
|
815
|
+
f"Invalid provider symbol {provider_symbol!r}: expected "
|
|
816
|
+
f"'provider:native_symbol' (e.g. 'capitalcom:AAPL').",
|
|
817
|
+
err=True, fg=colors.RED)
|
|
818
|
+
raise Exit(1)
|
|
819
|
+
|
|
820
|
+
path = app_state.config_dir / SYMBOL_MAP_FILENAME
|
|
821
|
+
key_line = f'"{tv_symbol}" = "{provider_symbol}"'
|
|
822
|
+
|
|
823
|
+
if not path.exists():
|
|
824
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
825
|
+
path.write_text(f"{_SYMBOL_MAP_HEADER}\n[symbol_map]\n{key_line}\n",
|
|
826
|
+
encoding="utf-8")
|
|
827
|
+
secho(f'Created {path} with {tv_symbol} -> {provider_symbol}')
|
|
828
|
+
return
|
|
829
|
+
|
|
830
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
831
|
+
key_prefix = f'"{tv_symbol}"'
|
|
832
|
+
replaced = False
|
|
833
|
+
for i, line in enumerate(lines):
|
|
834
|
+
stripped = line.strip()
|
|
835
|
+
if stripped.startswith(key_prefix):
|
|
836
|
+
after = stripped[len(key_prefix):].lstrip()
|
|
837
|
+
if after.startswith("="):
|
|
838
|
+
lines[i] = key_line
|
|
839
|
+
replaced = True
|
|
840
|
+
break
|
|
841
|
+
|
|
842
|
+
if not replaced:
|
|
843
|
+
# Insert right after the [symbol_map] header, or append the whole
|
|
844
|
+
# table when the file has no [symbol_map] section yet.
|
|
845
|
+
header_idx = next(
|
|
846
|
+
(i for i, ln in enumerate(lines) if ln.strip() == "[symbol_map]"), None)
|
|
847
|
+
if header_idx is None:
|
|
848
|
+
if lines and lines[-1].strip():
|
|
849
|
+
lines.append("")
|
|
850
|
+
lines.append("[symbol_map]")
|
|
851
|
+
lines.append(key_line)
|
|
852
|
+
else:
|
|
853
|
+
lines.insert(header_idx + 1, key_line)
|
|
854
|
+
|
|
855
|
+
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
856
|
+
verb = "Updated" if replaced else "Added"
|
|
857
|
+
secho(f'{verb} {tv_symbol} -> {provider_symbol} in {path}')
|