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,1149 @@
1
+ """
2
+ Interactive symbol browser TUI for the ``pyne data download`` command.
3
+
4
+ Renders a two-pane layout (scrollable symbol list on the left, live symbol
5
+ info on the right) on the alternate screen buffer via ``rich.live``. Symbol
6
+ info is fetched on demand on a single background worker, debounced so fast
7
+ scrolling does not flood the provider with requests, and cached in memory
8
+ with an LRU eviction policy.
9
+
10
+ Pressing ENTER on a symbol opens an inline timeframe + date wizard below the
11
+ panels. Submitting the wizard starts a download on a background worker; the
12
+ progress strip replaces the wizard until completion, after which the user
13
+ returns to the browse view and can pick another symbol.
14
+ """
15
+ import os
16
+ import shutil
17
+ import sys
18
+ import threading
19
+ import time
20
+ from collections import OrderedDict
21
+ from concurrent.futures import Future, ThreadPoolExecutor
22
+ from dataclasses import dataclass, field
23
+ from datetime import datetime
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from rich.console import Console, Group
28
+ from rich.highlighter import ReprHighlighter
29
+ from rich.layout import Layout
30
+ from rich.live import Live
31
+ from rich.panel import Panel
32
+ from rich.progress import BarColumn, Progress, TextColumn
33
+ from rich.table import Table
34
+ from rich.text import Text
35
+
36
+ from ...core.download_runner import (download_to_file, DownloadConflictError,
37
+ DownloadPlan, DownloadProgress)
38
+ from ...core.plugin import ProviderPlugin
39
+ from ...core.syminfo import SymInfo
40
+ from ..commands.data import parse_date_or_days, validate_timeframe
41
+ from .keyreader import Key, KeyOrChar, raw_terminal, read_key
42
+
43
+ _HIGHLIGHTER = ReprHighlighter()
44
+
45
+
46
+ # Time the cursor must stay on a symbol before we trigger a fetch — protects
47
+ # against bursting the provider during fast scrolling.
48
+ _FETCH_DEBOUNCE_S = 0.15
49
+
50
+ # A failed info fetch (e.g. a transient connection error) is cached only for
51
+ # this long, then re-fetched on the next visit. Successful SymInfo stays cached
52
+ # until LRU eviction — only errors expire, so transient failures self-heal
53
+ # without hammering the provider.
54
+ _ERROR_RETRY_COOLDOWN_S = 10.0
55
+
56
+ # Footer block is one line of help text inside a Panel (2 border lines).
57
+ _FOOTER_HEIGHT = 3
58
+
59
+ # Wizard / progress strip height when no dropdown is open. Panel chrome (2)
60
+ # + field row (1) + hint row (1) + a one-row breathing space below the field
61
+ # row to mirror the dropdown panel's vertical offset.
62
+ _STRIP_HEIGHT_BASE = 5
63
+
64
+ # Progress strip stays at the legacy fixed height (panel chrome + 1 bar row +
65
+ # breathing space).
66
+ _STRIP_HEIGHT_PROGRESS = 5
67
+
68
+ # Hardcoded chrome around the list (panel borders + title row).
69
+ _LIST_CHROME_LINES = 2
70
+
71
+ # Wizard field model + dropdown option lists.
72
+
73
+ CUSTOM_LABEL = "Custom..."
74
+
75
+ TF_OPTIONS = ["1", "5", "15", "30", "60", "240", "1D", "1W", "1M", CUSTOM_LABEL]
76
+ FROM_OPTIONS_RAW = ["continue", "1", "7", "30", "90", "180", "365", CUSTOM_LABEL]
77
+ FROM_DISPLAY = {
78
+ "continue": "continue",
79
+ "1": "1 day back",
80
+ "7": "7 days back",
81
+ "30": "30 days back",
82
+ "90": "90 days back",
83
+ "180": "180 days back",
84
+ "365": "365 days back",
85
+ CUSTOM_LABEL: "Custom date...",
86
+ }
87
+ TO_OPTIONS_RAW = ["now", CUSTOM_LABEL]
88
+ TO_DISPLAY = {"now": "now", CUSTOM_LABEL: "Custom date..."}
89
+ TRUNCATE_OPTIONS = ["No", "Yes"]
90
+
91
+ # Per-kind label shown in front of each field cell.
92
+ _KIND_LABELS = {
93
+ 'tf': "Timeframe",
94
+ 'from': "From",
95
+ 'to': "To",
96
+ 'truncate': "Truncate",
97
+ 'submit': "",
98
+ }
99
+
100
+ # Dropdown viewport size — max number of options visible at once. Scroll
101
+ # kicks in around the cursor when an option list grows past this; keeps the
102
+ # wizard strip from eating the symbol panel on small terminals.
103
+ _DROPDOWN_MAX_ROWS = 10
104
+
105
+
106
+ @dataclass
107
+ class WizardField:
108
+ """One focusable element in the inline download wizard.
109
+
110
+ ``kind`` drives the UX: ``tf|from|to`` get a dropdown + an inline
111
+ Custom-text mode, ``truncate`` is a toggle, ``submit`` is a button
112
+ whose ``Enter`` triggers download dispatch.
113
+ """
114
+ kind: str
115
+ value: str = ""
116
+ options: list[str] = field(default_factory=list)
117
+ active: bool = False
118
+ text_mode: bool = False
119
+ dd_cursor: int = 0
120
+ text_buffer: str = ""
121
+ pre_active_value: str = ""
122
+
123
+
124
+ def _option_display(kind: str, opt: str) -> str:
125
+ """Return the human-facing label for an option (kind-specific map)."""
126
+ if kind == 'from':
127
+ return FROM_DISPLAY.get(opt, opt)
128
+ if kind == 'to':
129
+ return TO_DISPLAY.get(opt, opt)
130
+ return opt
131
+
132
+
133
+ def _resolve_default_for_dropdown(value: str,
134
+ options: list[str]) -> tuple[int, str, bool]:
135
+ """Map a raw default onto (dd_cursor, field_value, is_custom).
136
+
137
+ Case-insensitive lookup against every option except ``Custom...``; if a
138
+ match is found, the cursor lands on it and the value snaps to the
139
+ option's canonical casing. Otherwise the cursor parks on ``Custom...``
140
+ and the raw value is preserved verbatim — submit-time validation will
141
+ catch garbage.
142
+ """
143
+ for i, opt in enumerate(options):
144
+ if opt == CUSTOM_LABEL:
145
+ continue
146
+ if value.strip().lower() == opt.strip().lower():
147
+ return i, opt, False
148
+ return options.index(CUSTOM_LABEL), value, True
149
+
150
+
151
+ class SymbolBrowser:
152
+ """Two-pane interactive browser for provider symbols with live info,
153
+ plus an inline timeframe + date wizard that drives ``download_ohlcv``."""
154
+
155
+ def __init__(self, provider: ProviderPlugin, symbols: list[str],
156
+ *,
157
+ ohlcv_dir: Path,
158
+ provider_string_prefix: str | None = None,
159
+ default_timeframe: str = "1D",
160
+ default_from: str = "continue",
161
+ default_to: str = "now",
162
+ default_chunk_size: int | None = None,
163
+ default_extra_data: bool = False,
164
+ can_go_back: bool = False,
165
+ max_cache: int = 200):
166
+ self.provider = provider
167
+ self.symbols: list[str] = list(symbols)
168
+ self.ohlcv_dir = ohlcv_dir
169
+ self.provider_string_prefix = provider_string_prefix
170
+ """Provider (+broker selector) prefix, e.g. ``"ccxt:BYBIT"`` — the
171
+ browsed symbols carry no such prefix. Used to persist the canonical
172
+ provider string next to a finished download; None disables that."""
173
+ # Broker selector for multi-broker providers, split off the prefix
174
+ # ("ccxt:BYBIT" -> "BYBIT"). Folded back before the browsed symbol when
175
+ # naming the .ohlcv file / building a download provider, so the broker
176
+ # survives in the filename (single-broker providers: None).
177
+ self._selector: str | None = (
178
+ provider_string_prefix.split(':', 1)[1]
179
+ if provider.multi_broker and provider_string_prefix
180
+ and ':' in provider_string_prefix
181
+ else None
182
+ )
183
+ self.default_chunk_size = default_chunk_size
184
+ self.default_extra_data = default_extra_data
185
+ self.max_cache = max_cache
186
+
187
+ # When launched from the multi-broker picker, ESC returns to the broker
188
+ # list instead of quitting the command. ``go_back`` records that intent
189
+ # for the caller; ``q`` still quits outright.
190
+ self.can_go_back = can_go_back
191
+ self.go_back: bool = False
192
+
193
+ # View state.
194
+ self.filtered: list[str] = list(self.symbols)
195
+ self.cursor: int = 0
196
+ self.scroll_offset: int = 0
197
+ self.filter_text: str = ''
198
+ self.filter_active: bool = False
199
+
200
+ # Fetch state.
201
+ self.info_cache: "OrderedDict[str, SymInfo | Exception]" = OrderedDict()
202
+ # monotonic time an error was cached, per symbol — drives error expiry.
203
+ self.error_since: dict[str, float] = {}
204
+ self.pending_symbol: str | None = None
205
+ self.pending_since: float = 0.0
206
+ self.executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=1)
207
+ self.active_future: Future | None = None
208
+ self.active_symbol: str | None = None
209
+
210
+ # Mode + wizard + download state.
211
+ self.mode: str = 'browse' # 'browse' | 'wizard' | 'downloading'
212
+ self._default_timeframe = default_timeframe
213
+ self._default_from = default_from
214
+ self._default_to = default_to
215
+ self.wiz_fields: list[WizardField] = self._build_wizard_fields()
216
+ self.wiz_focus: int = 0
217
+ self.wiz_error: str | None = None
218
+ self.dl_executor: ThreadPoolExecutor = ThreadPoolExecutor(max_workers=1)
219
+ self.dl_future: Future | None = None
220
+ self.dl_lock = threading.Lock()
221
+ self.dl_total_seconds: int = 1
222
+ self.dl_elapsed_seconds: int = 0
223
+ self.dl_started_monotonic: float = 0.0
224
+ self.dl_indeterminate: bool = False # fetch_all download: no known total
225
+ self.dl_label: str = ""
226
+ self.dl_status: str | None = None # last download result message
227
+ self.dl_status_ok: bool = True
228
+ self.dl_status_until: float = 0.0 # monotonic time when status should clear
229
+
230
+ # Resize state (Unix uses SIGWINCH; Windows polls size).
231
+ self.resize_event: threading.Event = threading.Event()
232
+ self.last_size: os.terminal_size = shutil.get_terminal_size()
233
+ self._old_sigwinch = None
234
+
235
+ # ---- cache --------------------------------------------------------
236
+
237
+ def _cache_get(self, key: str) -> "SymInfo | Exception | None":
238
+ if key in self.info_cache:
239
+ self.info_cache.move_to_end(key)
240
+ return self.info_cache[key]
241
+ return None
242
+
243
+ def _cache_put(self, key: str, value: "SymInfo | Exception") -> None:
244
+ self.info_cache[key] = value
245
+ self.info_cache.move_to_end(key)
246
+ while len(self.info_cache) > self.max_cache:
247
+ evicted, _ = self.info_cache.popitem(last=False)
248
+ self.error_since.pop(evicted, None)
249
+
250
+ # ---- fetch worker -------------------------------------------------
251
+
252
+ def _fetch_info(self, symbol: str) -> SymInfo:
253
+ # Runs on the worker thread. update_symbol_info() reads self.symbol,
254
+ # so we mutate it here. A single worker enforces serialization.
255
+ self.provider.symbol = symbol
256
+ return self.provider.update_symbol_info()
257
+
258
+ def _maybe_start_fetch(self, now: float) -> None:
259
+ if self.mode == 'downloading':
260
+ return
261
+ if self.active_future is not None:
262
+ return
263
+ if not self.filtered:
264
+ return
265
+ current = self.filtered[self.cursor]
266
+ cached = self.info_cache.get(current)
267
+ if cached is not None:
268
+ # A cached error is retried once its cooldown elapses; successful
269
+ # info stays put. This lets transient connection failures self-heal.
270
+ if isinstance(cached, Exception) and \
271
+ now - self.error_since.get(current, 0.0) >= _ERROR_RETRY_COOLDOWN_S:
272
+ self.info_cache.pop(current, None)
273
+ self.error_since.pop(current, None)
274
+ else:
275
+ self.pending_symbol = None
276
+ return
277
+ if self.pending_symbol != current:
278
+ self.pending_symbol = current
279
+ self.pending_since = now
280
+ return
281
+ if now - self.pending_since < _FETCH_DEBOUNCE_S:
282
+ return
283
+ self.active_symbol = current
284
+ self.active_future = self.executor.submit(self._fetch_info, current)
285
+
286
+ def _maybe_collect_result(self, now: float) -> None:
287
+ if self.active_future is None or not self.active_future.done():
288
+ return
289
+ sym = self.active_symbol
290
+ try:
291
+ result: SymInfo | Exception = self.active_future.result()
292
+ except BaseException as exc:
293
+ result = exc if isinstance(exc, Exception) else RuntimeError(repr(exc))
294
+ if sym is not None:
295
+ self._cache_put(sym, result)
296
+ # Errors expire after a cooldown; success is remembered permanently.
297
+ if isinstance(result, Exception):
298
+ self.error_since[sym] = now
299
+ else:
300
+ self.error_since.pop(sym, None)
301
+ self.active_future = None
302
+ self.active_symbol = None
303
+
304
+ # ---- filtering / navigation --------------------------------------
305
+
306
+ def _apply_filter(self) -> None:
307
+ if self.filter_text:
308
+ needle = self.filter_text.lower()
309
+ self.filtered = [s for s in self.symbols if needle in s.lower()]
310
+ else:
311
+ self.filtered = list(self.symbols)
312
+ if self.cursor >= len(self.filtered):
313
+ self.cursor = max(0, len(self.filtered) - 1)
314
+ self.scroll_offset = 0
315
+ self.pending_symbol = None
316
+
317
+ def _move_cursor(self, delta: int) -> None:
318
+ if not self.filtered:
319
+ self.cursor = 0
320
+ return
321
+ self.cursor = max(0, min(len(self.filtered) - 1, self.cursor + delta))
322
+
323
+ def _ensure_cursor_visible(self, list_height: int) -> None:
324
+ if list_height <= 0:
325
+ return
326
+ if self.cursor < self.scroll_offset:
327
+ self.scroll_offset = self.cursor
328
+ elif self.cursor >= self.scroll_offset + list_height:
329
+ self.scroll_offset = self.cursor - list_height + 1
330
+
331
+ # ---- key handling -------------------------------------------------
332
+
333
+ def _handle_key(self, key: KeyOrChar) -> bool:
334
+ """Return False to request exit, True to continue."""
335
+ if self.mode == 'downloading':
336
+ return True # ignore everything; Ctrl+C still escapes via signal
337
+ if self.mode == 'wizard':
338
+ return self._handle_wizard_key(key)
339
+ if self.filter_active:
340
+ return self._handle_filter_key(key)
341
+ return self._handle_normal_key(key)
342
+
343
+ def _handle_filter_key(self, key: KeyOrChar) -> bool:
344
+ if key is Key.ESC:
345
+ self.filter_active = False
346
+ if self.filter_text:
347
+ self.filter_text = ''
348
+ self._apply_filter()
349
+ return True
350
+ if key is Key.ENTER:
351
+ # Commit the filter and open the wizard in one step. The
352
+ # filter row's cursor already points at the selected symbol;
353
+ # forcing a second Enter to actually start the download felt
354
+ # like the first Enter was swallowed.
355
+ self.filter_active = False
356
+ self._enter_wizard()
357
+ return True
358
+ if key is Key.BACKSPACE:
359
+ if self.filter_text:
360
+ self.filter_text = self.filter_text[:-1]
361
+ self._apply_filter()
362
+ else:
363
+ self.filter_active = False
364
+ return True
365
+ # Navigation keys exit the filter (keeping the typed text as the
366
+ # active list filter) AND move the cursor, so the next Enter goes
367
+ # straight into the wizard instead of being absorbed by the
368
+ # filter-exit transition.
369
+ if key is Key.UP:
370
+ self.filter_active = False
371
+ self._move_cursor(-1)
372
+ return True
373
+ if key is Key.DOWN:
374
+ self.filter_active = False
375
+ self._move_cursor(1)
376
+ return True
377
+ if key is Key.PAGE_UP:
378
+ self.filter_active = False
379
+ self._move_cursor(-10)
380
+ return True
381
+ if key is Key.PAGE_DOWN:
382
+ self.filter_active = False
383
+ self._move_cursor(10)
384
+ return True
385
+ if isinstance(key, str) and key.isprintable():
386
+ self.filter_text += key
387
+ self._apply_filter()
388
+ return True
389
+ return True
390
+
391
+ def _handle_normal_key(self, key: KeyOrChar) -> bool:
392
+ if isinstance(key, str):
393
+ if key == 'q':
394
+ return False
395
+ if key == '/':
396
+ self.filter_active = True
397
+ return True
398
+ return True
399
+ if key is Key.ESC:
400
+ if self.can_go_back:
401
+ self.go_back = True
402
+ return False
403
+ if key is Key.ENTER:
404
+ self._enter_wizard()
405
+ return True
406
+ if key is Key.UP:
407
+ self._move_cursor(-1)
408
+ elif key is Key.DOWN:
409
+ self._move_cursor(1)
410
+ elif key is Key.PAGE_UP:
411
+ self._move_cursor(-10)
412
+ elif key is Key.PAGE_DOWN:
413
+ self._move_cursor(10)
414
+ elif key is Key.HOME:
415
+ self.cursor = 0
416
+ elif key is Key.END:
417
+ self.cursor = max(0, len(self.filtered) - 1)
418
+ return True
419
+
420
+ # ---- wizard field model ------------------------------------------
421
+
422
+ def _build_wizard_fields(self) -> list[WizardField]:
423
+ """Initialise the wizard with CLI defaults snapped onto dropdown
424
+ options where possible, else parked on ``Custom...`` verbatim."""
425
+ tf_idx, tf_val, _ = _resolve_default_for_dropdown(
426
+ self._default_timeframe, TF_OPTIONS)
427
+ from_idx, from_val, _ = _resolve_default_for_dropdown(
428
+ self._default_from, FROM_OPTIONS_RAW)
429
+ to_idx, to_val, _ = _resolve_default_for_dropdown(
430
+ self._default_to, TO_OPTIONS_RAW)
431
+ return [
432
+ WizardField(kind='tf', value=tf_val, options=list(TF_OPTIONS),
433
+ dd_cursor=tf_idx),
434
+ WizardField(kind='from', value=from_val,
435
+ options=list(FROM_OPTIONS_RAW), dd_cursor=from_idx),
436
+ WizardField(kind='to', value=to_val, options=list(TO_OPTIONS_RAW),
437
+ dd_cursor=to_idx),
438
+ WizardField(kind='submit'),
439
+ ]
440
+
441
+ def _field(self, kind: str) -> WizardField | None:
442
+ for f in self.wiz_fields:
443
+ if f.kind == kind:
444
+ return f
445
+ return None
446
+
447
+ def _filename_symbol(self, symbol: str) -> str:
448
+ """The browsed ``symbol`` folded with the broker selector, as the
449
+ provider constructor and :meth:`ProviderPlugin.get_ohlcv_path` expect
450
+ it (``"BYBIT:BTC/USDT:USDT"``). Keeps the broker in the ``.ohlcv``
451
+ filename for multi-broker providers; single-broker: symbol unchanged."""
452
+ return f"{self._selector}:{symbol}" if self._selector else symbol
453
+
454
+ def _target_file_exists(self) -> bool:
455
+ """Does the target OHLCV file already exist for the current
456
+ symbol + chosen TF? Drives both the Truncate toggle visibility
457
+ and the smart From default ('continue' if it exists, '365' otherwise)."""
458
+ if not self.filtered:
459
+ return False
460
+ tf_field = self._field('tf')
461
+ if tf_field is None:
462
+ return False
463
+ tf_raw = tf_field.value.strip()
464
+ if not tf_raw:
465
+ return False
466
+ try:
467
+ tf = validate_timeframe(tf_raw)
468
+ except ValueError:
469
+ return False
470
+ symbol = self.filtered[self.cursor]
471
+ # noinspection PyBroadException
472
+ try:
473
+ path = type(self.provider).get_ohlcv_path(
474
+ self._filename_symbol(symbol), tf, self.ohlcv_dir)
475
+ except Exception:
476
+ # Provider plugin code is arbitrary — swallow anything so the
477
+ # browser keeps running even if the plugin misbehaves.
478
+ return False
479
+ return path.exists()
480
+
481
+ def _refresh_truncate_visibility(self) -> None:
482
+ """Insert / remove the Truncate field based on file existence.
483
+
484
+ Idempotent — safe to call after every TF change or wizard entry.
485
+ Pre-existing toggle state is preserved if the field stays visible;
486
+ a removed-and-re-added field defaults to ``No``.
487
+ """
488
+ exists = self._target_file_exists()
489
+ existing = self._field('truncate')
490
+ if exists and existing is None:
491
+ # Insert just before the submit button.
492
+ submit_idx = next(i for i, f in enumerate(self.wiz_fields)
493
+ if f.kind == 'submit')
494
+ self.wiz_fields.insert(submit_idx, WizardField(
495
+ kind='truncate', value="No", options=list(TRUNCATE_OPTIONS),
496
+ dd_cursor=0,
497
+ ))
498
+ # Focus stays put — its index didn't move (we inserted before
499
+ # submit, which is the last item).
500
+ elif not exists and existing is not None:
501
+ removed_idx = self.wiz_fields.index(existing)
502
+ self.wiz_fields.remove(existing)
503
+ if self.wiz_focus >= removed_idx:
504
+ self.wiz_focus = max(0, self.wiz_focus - 1)
505
+
506
+ def _apply_smart_from_default(self) -> None:
507
+ """Switch the From field between 'continue' and '365' based on
508
+ whether the target OHLCV file already exists. Only fires when the
509
+ CLI default was 'continue' (no explicit --from override)."""
510
+ if self._default_from != "continue":
511
+ return
512
+ from_field = self._field('from')
513
+ if from_field is None:
514
+ return
515
+ new_value = "continue" if self._target_file_exists() else "365"
516
+ from_field.value = new_value
517
+ idx, _, _ = _resolve_default_for_dropdown(new_value, from_field.options)
518
+ from_field.dd_cursor = idx
519
+
520
+ # ---- enter / leave wizard ----------------------------------------
521
+
522
+ def _enter_wizard(self) -> None:
523
+ if not self.filtered:
524
+ return
525
+ self.mode = 'wizard'
526
+ self.wiz_focus = 0
527
+ self.wiz_error = None
528
+ for fld in self.wiz_fields:
529
+ fld.active = False
530
+ fld.text_mode = False
531
+ fld.text_buffer = ""
532
+ fld.pre_active_value = ""
533
+ # Pick a sensible From default based on whether the target file
534
+ # already exists — only when the user didn't override --from on
535
+ # the CLI (i.e. the original default was 'continue').
536
+ self._apply_smart_from_default()
537
+ self._refresh_truncate_visibility()
538
+ # Status from a previous download stays visible until the next
539
+ # mode transition — clear it here so the wizard hint row reads cleanly.
540
+ self.dl_status = None
541
+
542
+ # ---- wizard key dispatch -----------------------------------------
543
+
544
+ def _handle_wizard_key(self, key: KeyOrChar) -> bool:
545
+ if not self.wiz_fields:
546
+ return True
547
+ field_ = self.wiz_fields[self.wiz_focus]
548
+ if field_.text_mode:
549
+ return self._handle_text_input_key(key, field_)
550
+ if field_.active:
551
+ return self._handle_dropdown_key(key, field_)
552
+ return self._handle_wizard_inactive_key(key)
553
+
554
+ def _handle_wizard_inactive_key(self, key: KeyOrChar) -> bool:
555
+ if key is Key.ESC:
556
+ self.mode = 'browse'
557
+ self.wiz_error = None
558
+ return True
559
+ if key is Key.LEFT or key is Key.SHIFT_TAB:
560
+ self._focus_next(-1)
561
+ return True
562
+ if key is Key.RIGHT or key is Key.TAB:
563
+ self._focus_next(+1)
564
+ return True
565
+ if key is Key.ENTER:
566
+ self._activate_focused()
567
+ return True
568
+ # Up/Down inactive: no-op (dropdown is the only place those move).
569
+ return True
570
+
571
+ def _handle_dropdown_key(self, key: KeyOrChar,
572
+ field_: WizardField) -> bool:
573
+ if key is Key.ESC:
574
+ field_.active = False
575
+ field_.value = field_.pre_active_value
576
+ return True
577
+ if key is Key.UP:
578
+ field_.dd_cursor = (field_.dd_cursor - 1) % len(field_.options)
579
+ return True
580
+ if key is Key.DOWN:
581
+ field_.dd_cursor = (field_.dd_cursor + 1) % len(field_.options)
582
+ return True
583
+ if key is Key.ENTER:
584
+ chosen = field_.options[field_.dd_cursor]
585
+ if chosen == CUSTOM_LABEL:
586
+ # Drop into inline text-input. Seed buffer with the prior
587
+ # custom value if we already had one (i.e. the field was
588
+ # parked on Custom before); otherwise start empty.
589
+ was_custom = field_.pre_active_value not in field_.options
590
+ field_.text_buffer = field_.pre_active_value if was_custom else ""
591
+ field_.text_mode = True
592
+ field_.active = False
593
+ else:
594
+ field_.value = chosen
595
+ field_.active = False
596
+ if field_.kind == 'tf':
597
+ # TF change may toggle Truncate visibility.
598
+ self._refresh_truncate_visibility()
599
+ self.wiz_error = None
600
+ return True
601
+ return True
602
+
603
+ def _handle_text_input_key(self, key: KeyOrChar,
604
+ field_: WizardField) -> bool:
605
+ if key is Key.ESC:
606
+ field_.text_mode = False
607
+ field_.value = field_.pre_active_value
608
+ field_.text_buffer = ""
609
+ return True
610
+ if key is Key.ENTER:
611
+ field_.value = field_.text_buffer
612
+ field_.text_mode = False
613
+ field_.text_buffer = ""
614
+ if field_.kind == 'tf':
615
+ self._refresh_truncate_visibility()
616
+ self.wiz_error = None
617
+ return True
618
+ if key is Key.BACKSPACE:
619
+ field_.text_buffer = field_.text_buffer[:-1]
620
+ return True
621
+ if isinstance(key, str) and key.isprintable():
622
+ field_.text_buffer += key
623
+ return True
624
+ return True
625
+
626
+ def _focus_next(self, delta: int) -> None:
627
+ if not self.wiz_fields:
628
+ return
629
+ n = len(self.wiz_fields)
630
+ self.wiz_focus = (self.wiz_focus + delta) % n
631
+
632
+ def _activate_focused(self) -> None:
633
+ field_ = self.wiz_fields[self.wiz_focus]
634
+ if field_.kind in ('tf', 'from', 'to'):
635
+ field_.pre_active_value = field_.value
636
+ field_.active = True
637
+ # Position the dropdown cursor on whichever option matches the
638
+ # current value (case-insensitive); otherwise park on Custom...
639
+ idx, _, _ = _resolve_default_for_dropdown(
640
+ field_.value, field_.options)
641
+ field_.dd_cursor = idx
642
+ return
643
+ if field_.kind == 'truncate':
644
+ field_.value = "Yes" if field_.value == "No" else "No"
645
+ return
646
+ if field_.kind == 'submit':
647
+ self._submit_wizard()
648
+
649
+ # ---- wizard validation + dispatch --------------------------------
650
+
651
+ def _submit_wizard(self) -> None:
652
+ tf_field = self._field('tf')
653
+ from_field = self._field('from')
654
+ to_field = self._field('to')
655
+ trunc_field = self._field('truncate')
656
+ assert tf_field is not None and from_field is not None and to_field is not None
657
+
658
+ try:
659
+ tf = validate_timeframe(tf_field.value)
660
+ except ValueError as e:
661
+ self.wiz_error = f"Timeframe: {e}"
662
+ self.wiz_focus = self.wiz_fields.index(tf_field)
663
+ return
664
+ try:
665
+ from_value = parse_date_or_days(from_field.value)
666
+ except ValueError as e:
667
+ self.wiz_error = f"From: {e}"
668
+ self.wiz_focus = self.wiz_fields.index(from_field)
669
+ return
670
+ try:
671
+ to_value = parse_date_or_days(to_field.value)
672
+ except ValueError as e:
673
+ self.wiz_error = f"To: {e}"
674
+ self.wiz_focus = self.wiz_fields.index(to_field)
675
+ return
676
+ if not isinstance(to_value, datetime):
677
+ self.wiz_error = "To: 'continue' is not valid here, use a date or 'now'"
678
+ self.wiz_focus = self.wiz_fields.index(to_field)
679
+ return
680
+
681
+ truncate = (trunc_field is not None and trunc_field.value == "Yes")
682
+ self.wiz_error = None
683
+ self.mode = 'downloading'
684
+ symbol = self.filtered[self.cursor]
685
+ self.dl_label = f"{symbol} {tf}"
686
+ self.dl_elapsed_seconds = 0
687
+ self.dl_total_seconds = 1
688
+ self.dl_indeterminate = False
689
+ self.dl_started_monotonic = time.monotonic()
690
+ self.dl_status = None
691
+ self.dl_future = self.dl_executor.submit(
692
+ self._run_download, symbol, tf, from_value, to_value, truncate,
693
+ )
694
+
695
+ # ---- download worker (runs on dl_executor thread) ----------------
696
+
697
+ def _run_download(self, symbol: str, tf: str,
698
+ from_value: datetime | str, to_value: datetime,
699
+ truncate: bool) -> None:
700
+ try:
701
+ def on_start(plan: DownloadPlan) -> None:
702
+ with self.dl_lock:
703
+ self.dl_total_seconds = max(1, plan.total_seconds)
704
+ self.dl_elapsed_seconds = 0
705
+ # A fetch_all download has no known end, so no progress
706
+ # ticks arrive: show a pulsing bar instead of a stuck 0%
707
+ self.dl_indeterminate = plan.fetch_all
708
+
709
+ def on_progress(progress_info: DownloadProgress) -> None:
710
+ with self.dl_lock:
711
+ self.dl_elapsed_seconds = progress_info.elapsed_seconds
712
+
713
+ provider_string = (f"{self.provider_string_prefix}:{symbol}@{tf}"
714
+ if self.provider_string_prefix else None)
715
+ # A dedicated instance built from the full "broker:symbol" (like the
716
+ # CLI): its constructor derives the broker-qualified .ohlcv path, and
717
+ # a background download cannot mutate the symbol under the browser's
718
+ # cursor (self.provider stays bound to whatever is being previewed).
719
+ dl_provider = type(self.provider)(
720
+ symbol=self._filename_symbol(symbol), timeframe=tf,
721
+ ohlcv_dir=self.ohlcv_dir, config=self.provider.config)
722
+ download_to_file(
723
+ dl_provider,
724
+ time_from=from_value, time_to=to_value,
725
+ truncate=truncate,
726
+ chunk_size=self.default_chunk_size,
727
+ extra_data=self.default_extra_data,
728
+ on_start=on_start, on_progress=on_progress,
729
+ # No place to ask here: rather than silently dropping the
730
+ # user's data, report the conflict and let them re-run with
731
+ # the wizard's Truncate field set to Yes.
732
+ on_conflict='abort',
733
+ provider_string=provider_string,
734
+ )
735
+
736
+ with self.dl_lock:
737
+ self.dl_status = f"[OK] downloaded {self.dl_label}"
738
+ self.dl_status_ok = True
739
+ except DownloadConflictError as exc:
740
+ with self.dl_lock:
741
+ self.dl_status = f"[ERR] {exc} Set Truncate to Yes to overwrite it."
742
+ self.dl_status_ok = False
743
+ except BaseException as exc:
744
+ msg = str(exc) or repr(exc)
745
+ with self.dl_lock:
746
+ self.dl_status = f"[ERR] {type(exc).__name__}: {msg}"
747
+ self.dl_status_ok = False
748
+
749
+ def _maybe_collect_download(self) -> None:
750
+ if self.dl_future is None or not self.dl_future.done():
751
+ return
752
+ # _run_download stores its outcome on dl_status before returning;
753
+ # the future itself never raises (all exceptions are captured).
754
+ self.dl_future = None
755
+ self.mode = 'browse'
756
+ # OK lines fade after 4 s; error lines linger longer (8 s) so they
757
+ # cannot be missed if the user was looking away when the download
758
+ # ran. Either way an explicit ENTER / mode-change clears it sooner
759
+ # via ``_enter_wizard``.
760
+ linger_s = 4.0 if self.dl_status_ok else 8.0
761
+ self.dl_status_until = time.monotonic() + linger_s
762
+ # Symbol info on disk may have changed during the download — clear
763
+ # the in-memory cache entry for the downloaded symbol so a fresh
764
+ # SymInfo is fetched on next selection.
765
+ if self.dl_label:
766
+ sym = self.dl_label.split(' ', 1)[0]
767
+ self.info_cache.pop(sym, None)
768
+ self.error_since.pop(sym, None)
769
+
770
+ def _maybe_clear_status(self, now: float) -> None:
771
+ if self.dl_status is None:
772
+ return
773
+ if self.dl_status_until and now >= self.dl_status_until:
774
+ self.dl_status = None
775
+ self.dl_status_until = 0.0
776
+
777
+ # ---- rendering ----------------------------------------------------
778
+
779
+ def _render_list(self, height: int) -> Panel:
780
+ list_height = max(1, height - _LIST_CHROME_LINES)
781
+ self._ensure_cursor_visible(list_height)
782
+ end = self.scroll_offset + list_height
783
+ visible = self.filtered[self.scroll_offset:end]
784
+ lines: list[Text] = []
785
+ for i, sym in enumerate(visible):
786
+ idx = self.scroll_offset + i
787
+ if idx == self.cursor:
788
+ lines.append(Text(f"> {sym}", style="bold reverse"))
789
+ else:
790
+ lines.append(Text(f" {sym}"))
791
+ if not lines:
792
+ lines.append(Text(" (no matches)", style="dim"))
793
+ title_parts = [f"Symbols ({len(self.filtered)}/{len(self.symbols)})"]
794
+ if self.filter_active or self.filter_text:
795
+ cursor_marker = "_" if self.filter_active else ""
796
+ title_parts.append(f"/{self.filter_text}{cursor_marker}")
797
+ title = " ".join(title_parts)
798
+ return Panel(Group(*lines), title=title, title_align="left")
799
+
800
+ def _render_info(self) -> Panel:
801
+ if not self.filtered:
802
+ return Panel(Text("No symbol selected", style="dim"),
803
+ title="Info", title_align="left")
804
+ sym = self.filtered[self.cursor]
805
+ cached = self._cache_get(sym)
806
+ if cached is None:
807
+ body: Text | Table
808
+ if self.active_symbol == sym:
809
+ body = Text("Loading...", style="dim")
810
+ else:
811
+ body = Text("(press a moment to load)", style="dim")
812
+ return Panel(body, title=sym, title_align="left")
813
+ if isinstance(cached, Exception):
814
+ msg = str(cached) or repr(cached)
815
+ return Panel(Text(f"Error ({type(cached).__name__}): {msg}", style="red"),
816
+ title=sym, title_align="left")
817
+ return Panel(self._info_table(cached),
818
+ title=f"{cached.prefix}:{cached.ticker}",
819
+ title_align="left")
820
+
821
+ _DAY_NAMES = {0: "Mon", 1: "Tue", 2: "Wed", 3: "Thu", 4: "Fri", 5: "Sat", 6: "Sun"}
822
+
823
+ @staticmethod
824
+ def _info_table(info: SymInfo) -> Table:
825
+ table = Table.grid(padding=(0, 2))
826
+ table.add_column(justify="right", style="dim")
827
+ table.add_column()
828
+
829
+ def row(label: str, value: Any) -> None:
830
+ table.add_row(label, _HIGHLIGHTER(str(value)))
831
+
832
+ row("Description:", info.description)
833
+ row("Type:", info.type)
834
+ row("Currency:", info.currency)
835
+ if info.basecurrency:
836
+ row("Base currency:", info.basecurrency)
837
+ row("Mintick:", info.mintick)
838
+ row("Pricescale:", info.pricescale)
839
+ row("Minmove:", info.minmove)
840
+ row("Pointvalue:", info.pointvalue)
841
+ row("Volume type:", info.volumetype)
842
+ row("Timezone:", info.timezone)
843
+ if info.avg_spread is not None:
844
+ row("Avg spread:", info.avg_spread)
845
+ if info.taker_fee is not None:
846
+ row("Taker fee:", info.taker_fee)
847
+ if info.maker_fee is not None:
848
+ row("Maker fee:", info.maker_fee)
849
+ if info.opening_hours:
850
+ row("Opening hours:", SymbolBrowser._format_opening_hours(info.opening_hours))
851
+ return table
852
+
853
+ @staticmethod
854
+ def _format_opening_hours(intervals) -> str:
855
+ by_day: dict[int, list] = {}
856
+ for iv in intervals:
857
+ by_day.setdefault(iv.day, []).append(iv)
858
+ lines = []
859
+ for day in sorted(by_day.keys()):
860
+ label = SymbolBrowser._DAY_NAMES.get(day, f"D{day}")
861
+ day_ivs = sorted(by_day[day], key=lambda x: x.start)
862
+ parts = [f"{iv.start.strftime('%H:%M')}-{iv.end.strftime('%H:%M')}"
863
+ for iv in day_ivs]
864
+ lines.append(f"{label} {', '.join(parts)}")
865
+ return "\n".join(lines)
866
+
867
+ def _wizard_strip_height(self) -> int:
868
+ """Dynamic wizard strip height — grows when a dropdown is open."""
869
+ active = next((f for f in self.wiz_fields if f.active), None)
870
+ if active is None:
871
+ return _STRIP_HEIGHT_BASE
872
+ # Dropdown panel: top/bottom border (2) + min(len, MAX) option rows.
873
+ rows = min(len(active.options), _DROPDOWN_MAX_ROWS)
874
+ return _STRIP_HEIGHT_BASE + rows + 2
875
+
876
+ @staticmethod
877
+ def _render_field_cell(field_: WizardField, focused: bool) -> Text:
878
+ label = _KIND_LABELS.get(field_.kind, "")
879
+ line = Text()
880
+ if field_.kind == 'submit':
881
+ text = "[ Download ]"
882
+ line.append(text, style="bold reverse" if focused else "bold")
883
+ return line
884
+ if label:
885
+ line.append(f"{label}: ", style="bold")
886
+ if field_.text_mode:
887
+ # Inline text input: show the buffer with a block cursor.
888
+ body = f"[ {field_.text_buffer}█ ]"
889
+ line.append(body, style="reverse" if focused else "")
890
+ return line
891
+ # Selector view.
892
+ if field_.kind == 'truncate':
893
+ body = f"[ {field_.value} ]"
894
+ else:
895
+ body = f"[ {field_.value} ▾ ]"
896
+ line.append(body, style="reverse" if focused else "")
897
+ return line
898
+
899
+ @staticmethod
900
+ def _render_dropdown_panel(field_: WizardField) -> Panel:
901
+ n = len(field_.options)
902
+ view = min(n, _DROPDOWN_MAX_ROWS)
903
+ # Center a window of size ``view`` on the cursor where possible.
904
+ half = view // 2
905
+ start = max(0, min(field_.dd_cursor - half, n - view))
906
+ end = start + view
907
+ lines: list[Text] = []
908
+ max_label = max(
909
+ (len(_option_display(field_.kind, opt)) for opt in field_.options),
910
+ default=0,
911
+ )
912
+ for i in range(start, end):
913
+ opt = field_.options[i]
914
+ display = _option_display(field_.kind, opt)
915
+ pad = " " * (max_label - len(display))
916
+ line = Text()
917
+ if i == field_.dd_cursor:
918
+ line.append(f"> {display}{pad} <", style="reverse")
919
+ else:
920
+ # Bold the option that matches the field's currently
921
+ # committed value (independent of where the cursor sits).
922
+ style = "bold" if opt == field_.value else ""
923
+ line.append(f" {display}{pad} ", style=style)
924
+ lines.append(line)
925
+ return Panel(Group(*lines), border_style="dim", padding=(0, 1))
926
+
927
+ def _render_wizard_strip(self) -> Panel:
928
+ sym = self.filtered[self.cursor] if self.filtered else "?"
929
+ title = f"Download {sym}"
930
+ fields_table = Table.grid(padding=(0, 2))
931
+ for _ in self.wiz_fields:
932
+ fields_table.add_column()
933
+ cells = [self._render_field_cell(f, i == self.wiz_focus)
934
+ for i, f in enumerate(self.wiz_fields)]
935
+ fields_table.add_row(*cells)
936
+
937
+ active = next((f for f in self.wiz_fields if f.active), None)
938
+ focused = self.wiz_fields[self.wiz_focus]
939
+ if self.wiz_error:
940
+ hint: Text = Text(f"[!] {self.wiz_error}", style="red")
941
+ elif focused.text_mode:
942
+ if focused.kind in ('from', 'to'):
943
+ hint = Text(
944
+ "Type date: YYYY-MM-DD or YYYY-MM-DD HH:MM:SS "
945
+ "- Enter: confirm - Esc: cancel",
946
+ style="yellow",
947
+ )
948
+ else:
949
+ hint = Text(
950
+ "Type custom timeframe (e.g. 3, 90, 1D) "
951
+ "- Enter: confirm - Esc: cancel",
952
+ style="dim",
953
+ )
954
+ elif active is not None:
955
+ hint = Text(
956
+ "Up/Down: select - Enter: confirm - Esc: cancel",
957
+ style="dim",
958
+ )
959
+ elif focused.kind == 'truncate':
960
+ hint = Text(
961
+ "Enter: toggle - Yes will erase existing OHLCV file before download",
962
+ style="yellow",
963
+ )
964
+ else:
965
+ hint = Text(
966
+ "Tab / Left Right: switch field - Enter: open / confirm - Esc: back",
967
+ style="dim",
968
+ )
969
+
970
+ if active is not None:
971
+ body: Group = Group(fields_table, self._render_dropdown_panel(active),
972
+ hint)
973
+ else:
974
+ body = Group(fields_table, hint)
975
+ return Panel(body, title=title, title_align="left",
976
+ height=self._wizard_strip_height())
977
+
978
+ def _render_progress_strip(self) -> Panel:
979
+ with self.dl_lock:
980
+ elapsed = self.dl_elapsed_seconds
981
+ total = self.dl_total_seconds
982
+ started = self.dl_started_monotonic
983
+ indeterminate = self.dl_indeterminate
984
+ # The Progress widget is rebuilt on every render, so Rich's built-in
985
+ # TimeElapsedColumn / TimeRemainingColumn always saw a freshly-created
986
+ # task and reported 0:00:00 / -:--:--. Compute real wall-clock elapsed
987
+ # and ETA from the dl_started_monotonic anchor and the progress ratio,
988
+ # then feed them in as plain text fields.
989
+ elapsed_real = max(0.0, time.monotonic() - started) if started else 0.0
990
+ if indeterminate:
991
+ progress = Progress(
992
+ TextColumn("{task.fields[label]}", style="bold"),
993
+ BarColumn(),
994
+ TextColumn("{task.fields[elapsed_str]}", style="progress.elapsed"),
995
+ expand=True,
996
+ )
997
+ progress.add_task(
998
+ "download", total=None,
999
+ label=self.dl_label,
1000
+ elapsed_str=self._format_hms(elapsed_real),
1001
+ )
1002
+ return Panel(progress, title=f"Downloading {self.dl_label}",
1003
+ title_align="left", height=_STRIP_HEIGHT_PROGRESS)
1004
+ if 0 < elapsed < total:
1005
+ eta = elapsed_real * (total - elapsed) / elapsed
1006
+ eta_str = self._format_hms(eta)
1007
+ elif 0 < total <= elapsed:
1008
+ eta_str = "0:00:00"
1009
+ else:
1010
+ eta_str = "-:--:--"
1011
+ progress = Progress(
1012
+ TextColumn("{task.fields[label]}", style="bold"),
1013
+ BarColumn(),
1014
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
1015
+ TextColumn("{task.fields[elapsed_str]}", style="progress.elapsed"),
1016
+ "/",
1017
+ TextColumn("{task.fields[eta_str]}", style="progress.remaining"),
1018
+ expand=True,
1019
+ )
1020
+ progress.add_task(
1021
+ "download", total=total, completed=elapsed,
1022
+ label=self.dl_label,
1023
+ elapsed_str=self._format_hms(elapsed_real),
1024
+ eta_str=eta_str,
1025
+ )
1026
+ return Panel(progress, title=f"Downloading {self.dl_label}",
1027
+ title_align="left", height=_STRIP_HEIGHT_PROGRESS)
1028
+
1029
+ @staticmethod
1030
+ def _format_hms(seconds: float) -> str:
1031
+ total = int(seconds)
1032
+ h, rem = divmod(total, 3600)
1033
+ m, s = divmod(rem, 60)
1034
+ return f"{h}:{m:02d}:{s:02d}"
1035
+
1036
+ def _render_footer(self) -> Panel:
1037
+ if self.mode == 'wizard':
1038
+ help_text: Text = Text(
1039
+ "Edit fields - Tab / Up Down switch - Enter download - Esc back",
1040
+ style="dim",
1041
+ )
1042
+ elif self.mode == 'downloading':
1043
+ help_text = Text("Downloading... please wait", style="dim")
1044
+ else:
1045
+ parts = "Up Down: navigate - PgUp PgDn: jump 10 - /: search - Enter: download - q: quit"
1046
+ if self.can_go_back:
1047
+ parts += " - Esc: back to brokers"
1048
+ help_text = Text(parts, style="dim")
1049
+ if self.dl_status is not None:
1050
+ style = "green" if self.dl_status_ok else "red"
1051
+ status_line = Text(self.dl_status, style=style)
1052
+ return Panel(Group(status_line, help_text), height=_FOOTER_HEIGHT)
1053
+ return Panel(help_text, height=_FOOTER_HEIGHT)
1054
+
1055
+ def _build_layout(self, console: Console) -> Layout:
1056
+ height = max(_FOOTER_HEIGHT + 3, console.size.height)
1057
+ if self.mode == 'wizard':
1058
+ strip_height = self._wizard_strip_height()
1059
+ elif self.mode == 'downloading':
1060
+ strip_height = _STRIP_HEIGHT_PROGRESS
1061
+ else:
1062
+ strip_height = 0
1063
+ main_height = height - _FOOTER_HEIGHT - strip_height
1064
+ list_height = main_height
1065
+ layout = Layout()
1066
+ sections: list[Layout] = [Layout(name="main")]
1067
+ if self.mode == 'wizard':
1068
+ sections.append(Layout(self._render_wizard_strip(), name="strip",
1069
+ size=strip_height))
1070
+ elif self.mode == 'downloading':
1071
+ sections.append(Layout(self._render_progress_strip(), name="strip",
1072
+ size=strip_height))
1073
+ sections.append(Layout(self._render_footer(), name="footer",
1074
+ size=_FOOTER_HEIGHT))
1075
+ layout.split_column(*sections)
1076
+ layout["main"].split_row(
1077
+ Layout(self._render_list(list_height), name="list", ratio=1),
1078
+ Layout(self._render_info(), name="info", ratio=2),
1079
+ )
1080
+ return layout
1081
+
1082
+ # ---- resize handling ---------------------------------------------
1083
+
1084
+ def _install_sigwinch(self) -> None:
1085
+ if sys.platform == 'win32':
1086
+ return
1087
+ import signal
1088
+ self._old_sigwinch = signal.signal(
1089
+ signal.SIGWINCH, lambda *_: self.resize_event.set()
1090
+ )
1091
+
1092
+ def _restore_sigwinch(self) -> None:
1093
+ if sys.platform == 'win32' or self._old_sigwinch is None:
1094
+ return
1095
+ import signal
1096
+ signal.signal(signal.SIGWINCH, self._old_sigwinch)
1097
+ self._old_sigwinch = None
1098
+
1099
+ def _check_size_change(self) -> bool:
1100
+ """Windows fallback: detect resize by polling terminal size."""
1101
+ if sys.platform != 'win32':
1102
+ return False
1103
+ current = shutil.get_terminal_size()
1104
+ if current != self.last_size:
1105
+ self.last_size = current
1106
+ return True
1107
+ return False
1108
+
1109
+ # ---- run loop -----------------------------------------------------
1110
+
1111
+ def run(self) -> None:
1112
+ if not self.symbols:
1113
+ print("No symbols available.", file=sys.stderr)
1114
+ return
1115
+
1116
+ console = Console()
1117
+ self._install_sigwinch()
1118
+ try:
1119
+ with raw_terminal():
1120
+ with Live(
1121
+ self._build_layout(console),
1122
+ console=console,
1123
+ screen=True,
1124
+ auto_refresh=False,
1125
+ ) as live:
1126
+ self._main_loop(live, console)
1127
+ except KeyboardInterrupt:
1128
+ pass
1129
+ finally:
1130
+ self._restore_sigwinch()
1131
+ self.executor.shutdown(wait=False, cancel_futures=True)
1132
+ self.dl_executor.shutdown(wait=False, cancel_futures=True)
1133
+
1134
+ def _main_loop(self, live: Live, console: Console) -> None:
1135
+ while True:
1136
+ key = read_key(timeout=0.05)
1137
+ if key is not None:
1138
+ if not self._handle_key(key):
1139
+ return
1140
+ now = time.monotonic()
1141
+ self._maybe_collect_result(now)
1142
+ self._maybe_collect_download()
1143
+ self._maybe_clear_status(now)
1144
+ self._maybe_start_fetch(now)
1145
+ if self.resize_event.is_set():
1146
+ self.resize_event.clear()
1147
+ self._check_size_change()
1148
+ live.update(self._build_layout(console))
1149
+ live.refresh()