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,132 @@
1
+ """
2
+ Typer Command subclass that supports dynamic parameter injection by plugins.
3
+
4
+ Typer builds the command tree from its own ``TyperCommand``/``TyperOption``
5
+ classes. By passing ``cls=PluggableCommand`` to ``@app.command()``, plugins
6
+ can register extra ``--flags`` that appear in ``--help`` and are parsed
7
+ alongside built-in parameters.
8
+
9
+ Plugins describe their options with :class:`~pynecore.core.plugin.CLIOption`
10
+ and this module turns them into the parser objects Typer expects. Plugins
11
+ never construct those objects themselves: Click is not a PyneCore dependency,
12
+ and since Typer 0.26 it is not a Typer dependency either — Typer vendors a
13
+ reduced fork of it, whose parser cannot handle foreign ``click.Option``
14
+ instances.
15
+
16
+ Plugin parameters are separated from core parameters before the callback is
17
+ invoked, so the original function signature does not need to change. The
18
+ injected values are stored on ``ctx.plugin_params``.
19
+
20
+ Typer rebuilds the whole command tree on every invocation
21
+ (``typer.main.get_command`` is not cached), so registrations cannot live on a
22
+ single command instance — they would be lost the next time the tree is built.
23
+ The registry is therefore class-level, keyed by command name, and every rebuilt
24
+ :class:`PluggableCommand` reads its injected parameters back from it.
25
+ """
26
+
27
+ from typing import Any, TypeAlias
28
+
29
+ from typer.core import TyperCommand, TyperOption
30
+ # Typer has no public choice type; ``typer.main`` only re-exports this one
31
+ # noinspection PyProtectedMember
32
+ from typer._types import TyperChoice
33
+
34
+ from ..core.plugin import CLIOption
35
+
36
+ __all__ = ['PluggableCommand']
37
+
38
+ # The parser context class is Typer-version dependent — Click's own before
39
+ # Typer 0.26, Typer's vendored fork from 0.26 on — so it cannot be named
40
+ # portably here.
41
+ Context: TypeAlias = Any
42
+
43
+
44
+ def _build_option(spec: CLIOption) -> TyperOption:
45
+ """
46
+ Build the parser object Typer expects from a backend-agnostic option spec.
47
+
48
+ :param spec: The plugin-provided option description.
49
+ :return: A Typer option ready to be added to a command.
50
+ """
51
+ param_type: Any = TyperChoice(list(spec.choices)) if spec.choices is not None else spec.type
52
+ return TyperOption(
53
+ param_decls=list(spec.decls),
54
+ help=spec.help or None,
55
+ default=spec.default,
56
+ # ``None`` leaves the flag/value decision to Typer's own inference
57
+ is_flag=spec.is_flag or None,
58
+ type=param_type,
59
+ metavar=spec.metavar,
60
+ required=spec.required,
61
+ multiple=spec.multiple,
62
+ hidden=spec.hidden,
63
+ envvar=spec.envvar,
64
+ rich_help_panel=spec.rich_help_panel,
65
+ )
66
+
67
+
68
+ class PluggableCommand(TyperCommand):
69
+ """
70
+ A Typer command that allows plugins to inject parameters.
71
+
72
+ Usage::
73
+
74
+ @app.command(cls=PluggableCommand)
75
+ def run(ctx: typer.Context, script: Path = ...):
76
+ live = ctx.plugin_params.get('live', False)
77
+
78
+ After the command is registered, call :meth:`register_plugin_param` to add
79
+ plugin-provided options.
80
+ """
81
+
82
+ # Command-name -> injected params. Class-level so it survives Typer
83
+ # rebuilding the command tree on each invocation. Command names are unique
84
+ # leaves (e.g. "run", "download"), so the leaf name is a safe key.
85
+ _plugin_param_registry: dict[str, list[TyperOption]] = {}
86
+
87
+ def register_plugin_param(self, spec: CLIOption) -> bool:
88
+ """
89
+ Register a plugin-provided parameter for this command.
90
+
91
+ Checks both parameter names and option strings (e.g. ``--from``, ``-f``)
92
+ against the core parameters and already-registered plugin parameters to
93
+ prevent conflicts.
94
+
95
+ :param spec: The option to inject.
96
+ :return: ``False`` if the name or any option string conflicts.
97
+ """
98
+ param = _build_option(spec)
99
+ registered = self._plugin_param_registry.setdefault(self.name or "", [])
100
+ all_params = [*self.params, *registered]
101
+
102
+ existing_names = {p.name for p in all_params}
103
+ if param.name in existing_names:
104
+ return False
105
+
106
+ existing_opts = {opt for p in all_params for opt in getattr(p, 'opts', ())}
107
+ new_opts = set(getattr(param, 'opts', ()))
108
+ if existing_opts & new_opts:
109
+ return False
110
+
111
+ registered.append(param)
112
+ return True
113
+
114
+ def _plugin_params(self) -> list[TyperOption]:
115
+ """Injected parameters registered for this command name."""
116
+ return self._plugin_param_registry.get(self.name or "", [])
117
+
118
+ def get_params(self, ctx: Context) -> list[Any]:
119
+ """Return core params + plugin params + help option."""
120
+ rv = [*self.params, *self._plugin_params()]
121
+ help_option = self.get_help_option(ctx)
122
+ if help_option is not None:
123
+ rv.append(help_option)
124
+ return rv
125
+
126
+ def invoke(self, ctx: Context) -> None:
127
+ """Pop plugin params from ctx.params before calling the callback."""
128
+ ctx.plugin_params = {}
129
+ for p in self._plugin_params():
130
+ if p.name in ctx.params:
131
+ ctx.plugin_params[p.name] = ctx.params.pop(p.name)
132
+ return super().invoke(ctx)
File without changes
@@ -0,0 +1,168 @@
1
+ """Centralized API error handling utilities for CLI commands."""
2
+
3
+ import re
4
+
5
+ from rich.console import Console
6
+ from typer import Exit
7
+
8
+ from pynecore.pynesys.api import APIError, AuthError, RateLimitError, CompilationError
9
+
10
+
11
+ class APIErrorHandler:
12
+ """Context manager that provides centralized API error handling."""
13
+
14
+ def __init__(self, console: Console | None = None):
15
+ self.console: Console = console or Console()
16
+
17
+ def __enter__(self):
18
+ return self
19
+
20
+ def __exit__(self, exc_type, exc_value, traceback):
21
+ if exc_type is None:
22
+ return False
23
+
24
+ if exc_type == CompilationError:
25
+ self._handle_compilation_error(exc_value)
26
+ elif exc_type == AuthError:
27
+ self._handle_auth_error(exc_value)
28
+ elif exc_type == RateLimitError:
29
+ self._handle_rate_limit_error(exc_value)
30
+ elif exc_type == APIError:
31
+ self._handle_api_error(exc_value)
32
+ else:
33
+ return False # Let other exceptions propagate
34
+
35
+ raise Exit(1)
36
+
37
+ def _handle_compilation_error(self, e: CompilationError):
38
+ """Handle compilation-specific errors."""
39
+ self.console.print(f"[red]Oops! Compilation encountered an issue:[/red] {str(e)}")
40
+ if e.validation_errors:
41
+ self.console.print("[red]Validation errors:[/red]")
42
+ for error in e.validation_errors:
43
+ self.console.print(f" [red]• {error}[/red]")
44
+
45
+ def _handle_auth_error(self, e: AuthError):
46
+ """Handle authentication errors."""
47
+ self.console.print(f"[red]Authentication issue:[/red] {str(e)}")
48
+ self.console.print("[yellow]To fix:[/yellow] Check [cyan]api_key[/cyan] in [cyan]api.toml[/cyan] "
49
+ "in your working directory")
50
+
51
+ def _handle_rate_limit_error(self, e: RateLimitError):
52
+ """Handle rate limit errors."""
53
+ self.console.print("[red]Rate Limit Exceeded:[/red] You've hit your compilation limit")
54
+ if e.retry_after:
55
+ self.console.print(f"[yellow]Please try again in {e.retry_after} seconds[/yellow]")
56
+ self.console.print(
57
+ "[yellow]To increase your limits, consider upgrading your subscription at "
58
+ "[link=https://pynesys.io]https://pynesys.io[/link]")
59
+
60
+ def _handle_api_error(self, e: APIError):
61
+ """Handle general API errors with specific status code handling."""
62
+ error_msg = str(e).lower()
63
+
64
+ # Handle specific API error scenarios based on HTTP status codes
65
+ has_400_error = "400" in error_msg or "bad request" in error_msg
66
+ has_structured_error = (
67
+ '"detail":' in error_msg and
68
+ '"error":' in error_msg and
69
+ '"line":' in error_msg
70
+ )
71
+
72
+ if has_400_error or has_structured_error:
73
+ if "compilation fails" in error_msg or "script is too large" in error_msg:
74
+ self.console.print("[red]Script Issue:[/red] Your Pine Script couldn't be compiled")
75
+ self.console.print("[yellow]Common fixes:[/yellow]")
76
+ self.console.print(" • Check if your script is too large (try breaking it into smaller parts)")
77
+ self.console.print(" • Verify your Pine Script syntax is correct")
78
+ self.console.print(" • Make sure you're using Pine Script v6 syntax")
79
+ else:
80
+ # Try to parse structured error response (JSON-like format)
81
+ error_str = str(e)
82
+ structured_error_parsed = False
83
+
84
+ # Check for structured JSON error format
85
+ has_error_and_line = (
86
+ ("'error':" in error_str and "'line':" in error_str) or
87
+ ('"error":' in error_str and '"line":' in error_str)
88
+ )
89
+
90
+ if has_error_and_line:
91
+ # Pattern for JSON format: {"detail":{"error":"...","line":...,"file":"..."}}
92
+ json_pattern = (
93
+ r'\{"detail":\{"status":"error","error":"([^"]+)",' +
94
+ r'"line":(\d+),"file":"([^"]+)"\}\}'
95
+ )
96
+ match = re.search(json_pattern, error_str)
97
+ if match:
98
+ error_msg, line_num, file_name = match.groups()
99
+ self.console.print(f"[red]Script Error:[/red] {error_msg}")
100
+ self.console.print(
101
+ f"[yellow]Location:[/yellow] Line {line_num} in {file_name}"
102
+ )
103
+ self.console.print(
104
+ "[yellow]Quick fix:[/yellow] Check the variable declaration and spelling"
105
+ )
106
+ structured_error_parsed = True
107
+
108
+ # Fallback to generic error display if structured parsing failed
109
+ if not structured_error_parsed:
110
+ self.console.print(f"[red]Script Error:[/red] {str(e)}")
111
+ self.console.print("[yellow]Common causes:[/yellow]")
112
+ self.console.print(" • Pine Script syntax errors")
113
+ self.console.print(" • Unsupported Pine Script features")
114
+ self.console.print(" • Incorrect variable declarations or usage")
115
+
116
+ elif "401" in error_msg or "authentication" in error_msg or "no permission" in error_msg:
117
+ self.console.print("[red]Authentication Failed:[/red] Your API credentials aren't working")
118
+ self.console.print("[yellow]Quick fixes:[/yellow]")
119
+ self.console.print(" • Check if your API key is valid and active")
120
+ self.console.print(" • Verify your token type is allowed for compilation")
121
+ self.console.print(
122
+ "Get a new API key at [link=https://pynesys.io]https://pynesys.io[/link]")
123
+ self.console.print(
124
+ "Then run [cyan]'pyne api configure'[/cyan] to update your configuration")
125
+
126
+ elif "404" in error_msg or "not found" in error_msg:
127
+ self.console.print("[red]Not Found:[/red] The API endpoint or user wasn't found")
128
+ self.console.print("[yellow]This might indicate:[/yellow]")
129
+ self.console.print(" • Your account may not exist or be accessible")
130
+ self.console.print(" • There might be a temporary service issue")
131
+
132
+ elif "422" in error_msg or "validation error" in error_msg:
133
+ self.console.print("[red]Validation Error:[/red] Your request data has validation issues")
134
+ self.console.print("[yellow]Common causes:[/yellow]")
135
+ self.console.print(" • Invalid Pine Script syntax or structure")
136
+ self.console.print(" • Missing required parameters")
137
+ self.console.print(" • Incorrect data format")
138
+ self.console.print(f"[dim]Details: {str(e)}[/dim]")
139
+
140
+ elif "429" in error_msg or "rate limit" in error_msg or "too many requests" in error_msg:
141
+ self.console.print("[red]Rate Limit Exceeded:[/red] You've hit your compilation limit")
142
+ self.console.print("[yellow]What you can do:[/yellow]")
143
+ self.console.print(" • Wait a bit before trying again")
144
+ self.console.print(" • Consider upgrading your plan for higher limits")
145
+
146
+ elif "500" in error_msg or "server" in error_msg or "internal" in error_msg:
147
+ self.console.print("[red]Server Error:[/red] Something went wrong on our end")
148
+ self.console.print(" • This is a temporary server issue")
149
+ self.console.print(" • Please try again in a few moments")
150
+
151
+ elif "unsupported pinescript version" in error_msg:
152
+ self.console.print("[red]Version Issue:[/red] Your Pine Script version isn't supported")
153
+ if "version 5" in error_msg:
154
+ self.console.print("[yellow]Pine Script v5 → v6 Migration:[/yellow]")
155
+ self.console.print(" • Update your script to Pine Script version 6")
156
+ self.console.print(" • Most v5 scripts need minimal changes")
157
+ else:
158
+ self.console.print("[yellow]Only Pine Script version 6 is currently supported[/yellow]")
159
+
160
+ elif "api key" in error_msg:
161
+ self.console.print("[red]API Key Issue:[/red] There's a problem with your API key")
162
+ self.console.print("Get your API key at [link=https://pynesys.io]https://pynesys.io[/link]")
163
+ self.console.print(
164
+ "Then run [cyan]'pyne api configure'[/cyan] to set up your configuration")
165
+
166
+ else:
167
+ # Generic API error fallback
168
+ self.console.print(f"[red]API Error:[/red] {str(e)}")
@@ -0,0 +1,330 @@
1
+ """
2
+ Interactive broker / exchange picker TUI for multi-broker data providers.
3
+
4
+ Multi-broker providers (CCXT serves ~100 crypto exchanges, cTrader serves every
5
+ broker the user holds an account with) need a backend chosen before a symbol can
6
+ be browsed. This module renders a single-pane, filterable, scrollable list on the
7
+ alternate screen buffer via ``rich.live`` and returns the chosen broker id, or
8
+ ``None`` if the user quits without picking.
9
+
10
+ Navigation mirrors :class:`~pynecore.cli.utils.symbol_browser.SymbolBrowser`:
11
+ arrow keys / PgUp / PgDn / Home / End move the cursor, ``/`` starts a substring
12
+ filter, ENTER selects, ``q`` / ESC quits.
13
+ """
14
+ import shutil
15
+ import sys
16
+ import threading
17
+ from typing import Protocol, Sequence
18
+
19
+ from rich.console import Console, Group
20
+ from rich.layout import Layout
21
+ from rich.live import Live
22
+ from rich.panel import Panel
23
+ from rich.text import Text
24
+
25
+ from .keyreader import Key, KeyOrChar, raw_terminal, read_key
26
+
27
+ # Footer block is one line of help text inside a Panel (2 border lines).
28
+ _FOOTER_HEIGHT = 3
29
+
30
+ # Panel top + bottom border lines deducted from the list panel height.
31
+ _LIST_CHROME_LINES = 2
32
+
33
+
34
+ class PickerItem(Protocol):
35
+ """Structural type for an item shown by :class:`BrokerPicker`."""
36
+
37
+ @property
38
+ def id(self) -> str:
39
+ ...
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ ...
44
+
45
+
46
+ class BrokerPicker:
47
+ """Single-pane filterable list of broker / exchange ids.
48
+
49
+ Each row shows the id selector and, when the provider supplies one, a
50
+ human-readable name in a second column. Filtering matches either column;
51
+ selection returns the chosen broker's id.
52
+
53
+ :ivar selected: The chosen broker id once :meth:`run` returns, else ``None``.
54
+ """
55
+
56
+ def __init__(self, brokers: Sequence[PickerItem], *, provider_name: str,
57
+ item_name: str = "brokers"):
58
+ """
59
+ :param brokers: The brokers / exchanges to choose from.
60
+ :param provider_name: Provider name shown in the panel title.
61
+ :param item_name: Plural name shown after ``provider_name`` in the title.
62
+ """
63
+ self.brokers: list[PickerItem] = list(brokers)
64
+ self.provider_name = provider_name
65
+ self.item_name = item_name
66
+
67
+ # View state.
68
+ self.filtered: list[PickerItem] = list(self.brokers)
69
+ self.cursor: int = 0
70
+ self.scroll_offset: int = 0
71
+ self.filter_text: str = ''
72
+ self.filter_active: bool = False
73
+
74
+ # Result.
75
+ self.selected: str | None = None
76
+
77
+ # Optional error shown above the help footer. The caller sets it before
78
+ # re-running the picker when the chosen broker could not be opened (e.g.
79
+ # an exchange that needs API credentials), so the user can pick another
80
+ # without the command exiting.
81
+ self.error: str | None = None
82
+
83
+ # Resize state (Unix uses SIGWINCH; Windows polls size).
84
+ self.resize_event: threading.Event = threading.Event()
85
+ self.last_size: object = shutil.get_terminal_size()
86
+ self._old_sigwinch = None
87
+
88
+ # ---- filter + navigation -----------------------------------------
89
+
90
+ def _apply_filter(self) -> None:
91
+ if self.filter_text:
92
+ needle = self.filter_text.lower()
93
+ self.filtered = [b for b in self.brokers
94
+ if needle in b.id.lower() or needle in b.name.lower()]
95
+ else:
96
+ self.filtered = list(self.brokers)
97
+ if self.cursor >= len(self.filtered):
98
+ self.cursor = max(0, len(self.filtered) - 1)
99
+ self.scroll_offset = 0
100
+
101
+ def _move_cursor(self, delta: int) -> None:
102
+ if not self.filtered:
103
+ self.cursor = 0
104
+ return
105
+ self.cursor = max(0, min(len(self.filtered) - 1, self.cursor + delta))
106
+
107
+ def _ensure_cursor_visible(self, list_height: int) -> None:
108
+ if list_height <= 0:
109
+ return
110
+ if self.cursor < self.scroll_offset:
111
+ self.scroll_offset = self.cursor
112
+ elif self.cursor >= self.scroll_offset + list_height:
113
+ self.scroll_offset = self.cursor - list_height + 1
114
+
115
+ # ---- key handling -------------------------------------------------
116
+
117
+ def _handle_key(self, key: KeyOrChar) -> bool:
118
+ """Return False to request exit, True to continue."""
119
+ prev_cursor = self.cursor
120
+ prev_filter = self.filter_text
121
+ if self.filter_active:
122
+ result = self._handle_filter_key(key)
123
+ else:
124
+ result = self._handle_normal_key(key)
125
+ # A broker-open error belongs to the row it was raised on; once the user
126
+ # moves the highlight or changes the filter it is stale and misleading,
127
+ # so clear it as soon as the view selection changes.
128
+ if self.cursor != prev_cursor or self.filter_text != prev_filter:
129
+ self.error = None
130
+ return result
131
+
132
+ def _select_current(self) -> bool:
133
+ """Commit the highlighted broker's id and request exit."""
134
+ if self.filtered:
135
+ self.selected = self.filtered[self.cursor].id
136
+ return False
137
+
138
+ def _handle_filter_key(self, key: KeyOrChar) -> bool:
139
+ if key is Key.ESC:
140
+ self.filter_active = False
141
+ if self.filter_text:
142
+ self.filter_text = ''
143
+ self._apply_filter()
144
+ return True
145
+ if key is Key.ENTER:
146
+ self.filter_active = False
147
+ return self._select_current()
148
+ if key is Key.BACKSPACE:
149
+ if self.filter_text:
150
+ self.filter_text = self.filter_text[:-1]
151
+ self._apply_filter()
152
+ else:
153
+ self.filter_active = False
154
+ return True
155
+ # Navigation keys exit the filter (keeping the typed text as the active
156
+ # list filter) and move the cursor, mirroring the symbol browser.
157
+ if key is Key.UP:
158
+ self.filter_active = False
159
+ self._move_cursor(-1)
160
+ return True
161
+ if key is Key.DOWN:
162
+ self.filter_active = False
163
+ self._move_cursor(1)
164
+ return True
165
+ if key is Key.PAGE_UP:
166
+ self.filter_active = False
167
+ self._move_cursor(-10)
168
+ return True
169
+ if key is Key.PAGE_DOWN:
170
+ self.filter_active = False
171
+ self._move_cursor(10)
172
+ return True
173
+ if isinstance(key, str) and key.isprintable():
174
+ self.filter_text += key
175
+ self._apply_filter()
176
+ return True
177
+ return True
178
+
179
+ def _handle_normal_key(self, key: KeyOrChar) -> bool:
180
+ if isinstance(key, str):
181
+ if key == 'q':
182
+ return False
183
+ if key == '/':
184
+ self.filter_active = True
185
+ return True
186
+ if key is Key.ESC:
187
+ return False
188
+ if key is Key.ENTER:
189
+ return self._select_current()
190
+ if key is Key.UP:
191
+ self._move_cursor(-1)
192
+ elif key is Key.DOWN:
193
+ self._move_cursor(1)
194
+ elif key is Key.PAGE_UP:
195
+ self._move_cursor(-10)
196
+ elif key is Key.PAGE_DOWN:
197
+ self._move_cursor(10)
198
+ elif key is Key.HOME:
199
+ self.cursor = 0
200
+ elif key is Key.END:
201
+ self.cursor = max(0, len(self.filtered) - 1)
202
+ return True
203
+
204
+ # ---- rendering ----------------------------------------------------
205
+
206
+ def _render_list(self, height: int) -> Panel:
207
+ list_height = max(1, height - _LIST_CHROME_LINES)
208
+ self._ensure_cursor_visible(list_height)
209
+ end = self.scroll_offset + list_height
210
+ visible = self.filtered[self.scroll_offset:end]
211
+ # Align the name column to the widest id in the full list so it stays
212
+ # put while scrolling/filtering; only pad when at least one name exists.
213
+ id_width = max((len(b.id) for b in self.brokers), default=0)
214
+ has_names = any(b.name for b in self.brokers)
215
+ lines: list[Text] = []
216
+ for i, broker in enumerate(visible):
217
+ idx = self.scroll_offset + i
218
+ if has_names:
219
+ label = f"{broker.id:<{id_width}} {broker.name}".rstrip()
220
+ else:
221
+ label = broker.id
222
+ if idx == self.cursor:
223
+ lines.append(Text(f"> {label}", style="bold reverse"))
224
+ else:
225
+ lines.append(Text(f" {label}"))
226
+ if not lines:
227
+ lines.append(Text(" (no matches)", style="dim"))
228
+ title_parts = [f"{self.provider_name} {self.item_name} "
229
+ f"({len(self.filtered)}/{len(self.brokers)})"]
230
+ if self.filter_active or self.filter_text:
231
+ cursor_marker = "_" if self.filter_active else ""
232
+ title_parts.append(f"/{self.filter_text}{cursor_marker}")
233
+ title = " ".join(title_parts)
234
+ return Panel(Group(*lines), title=title, title_align="left")
235
+
236
+ def _footer_height(self) -> int:
237
+ """Footer grows by one row when an error line is shown."""
238
+ return _FOOTER_HEIGHT + (1 if self.error else 0)
239
+
240
+ def _render_footer(self) -> Panel:
241
+ help_text = Text(
242
+ "Up Down: navigate - PgUp PgDn: jump 10 - /: search - "
243
+ "Enter: select - q: quit",
244
+ style="dim",
245
+ )
246
+ if self.error:
247
+ return Panel(Group(Text(self.error, style="red"), help_text),
248
+ height=self._footer_height())
249
+ return Panel(help_text, height=_FOOTER_HEIGHT)
250
+
251
+ def _build_layout(self, console: Console) -> Layout:
252
+ footer_height = self._footer_height()
253
+ height = max(footer_height + 3, console.size.height)
254
+ list_height = height - footer_height
255
+ layout = Layout()
256
+ layout.split_column(
257
+ Layout(self._render_list(list_height), name="list"),
258
+ Layout(self._render_footer(), name="footer", size=footer_height),
259
+ )
260
+ return layout
261
+
262
+ # ---- resize handling ---------------------------------------------
263
+
264
+ def _install_sigwinch(self) -> None:
265
+ if sys.platform == 'win32':
266
+ return
267
+ import signal
268
+ self._old_sigwinch = signal.signal(
269
+ signal.SIGWINCH, lambda *_: self.resize_event.set()
270
+ )
271
+
272
+ def _restore_sigwinch(self) -> None:
273
+ if sys.platform == 'win32' or self._old_sigwinch is None:
274
+ return
275
+ import signal
276
+ signal.signal(signal.SIGWINCH, self._old_sigwinch)
277
+ self._old_sigwinch = None
278
+
279
+ def _check_size_change(self) -> bool:
280
+ """Windows fallback: detect resize by polling terminal size."""
281
+ if sys.platform != 'win32':
282
+ return False
283
+ current = shutil.get_terminal_size()
284
+ if current != self.last_size:
285
+ self.last_size = current
286
+ return True
287
+ return False
288
+
289
+ # ---- run loop -----------------------------------------------------
290
+
291
+ def run(self) -> str | None:
292
+ """Render the picker and block until the user selects or quits.
293
+
294
+ :return: The chosen broker id, or ``None`` if the user quit.
295
+ """
296
+ if not self.brokers:
297
+ print(f"No {self.item_name} available.", file=sys.stderr)
298
+ return None
299
+
300
+ # Reset so the picker can be re-run after a failed broker attempt.
301
+ self.selected = None
302
+
303
+ console = Console()
304
+ self._install_sigwinch()
305
+ try:
306
+ with raw_terminal():
307
+ with Live(
308
+ self._build_layout(console),
309
+ console=console,
310
+ screen=True,
311
+ auto_refresh=False,
312
+ ) as live:
313
+ self._main_loop(live, console)
314
+ except KeyboardInterrupt:
315
+ return None
316
+ finally:
317
+ self._restore_sigwinch()
318
+ return self.selected
319
+
320
+ def _main_loop(self, live: Live, console: Console) -> None:
321
+ while True:
322
+ key = read_key(timeout=0.05)
323
+ if key is not None:
324
+ if not self._handle_key(key):
325
+ return
326
+ if self.resize_event.is_set():
327
+ self.resize_event.clear()
328
+ self._check_size_change()
329
+ live.update(self._build_layout(console))
330
+ live.refresh()
@@ -0,0 +1,28 @@
1
+ import sys
2
+ import traceback
3
+ from pathlib import Path
4
+
5
+
6
+ def setup_global_error_logging(log_path: Path):
7
+ """
8
+ Sets up a global error logging mechanism that writes uncaught exceptions to a logfile.
9
+
10
+ :param log_path: Path to the log file where uncaught exceptions will be written.
11
+ """
12
+ # Creating the log file directory
13
+ log_path.parent.mkdir(parents=True, exist_ok=True)
14
+ # Remove last error file if exists
15
+ if log_path.exists():
16
+ log_path.unlink()
17
+
18
+ # Save the original excepthook
19
+ original_excepthook = sys.excepthook
20
+
21
+ # Exception handler – saves the raw stack trace to a file
22
+ def log_and_reraise(exc_type, exc_value, tb):
23
+ tb_str = ''.join(traceback.format_exception(exc_type, exc_value, tb))
24
+ log_path.write_text(tb_str, encoding="utf-8")
25
+ original_excepthook(exc_type, exc_value, tb)
26
+
27
+ # Set the new excepthook
28
+ sys.excepthook = log_and_reraise