opencode-pyneruntime 6.6.4__py3-none-any.whl

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