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