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,187 @@
1
+ """
2
+ Replay provider — a deterministic, fixture-fed live provider for tests.
3
+
4
+ Feeds a security subprocess a fixed, pre-recorded sequence of warmup and live
5
+ OHLCV bars from a JSON fixture file, with no network and no sleeps. Drives a
6
+ real spawned security child through the streaming path deterministically (used
7
+ by the live ``request.security_lower_tf`` window end-to-end test).
8
+
9
+ Fixture format (UTF-8 JSON)::
10
+
11
+ {
12
+ "warmup": [[ts, open, high, low, close, volume], ...],
13
+ "live": [[ts, open, high, low, close, volume, is_closed], ...]
14
+ }
15
+
16
+ ``warmup`` rows are always closed bars (returned by :meth:`download_ohlcv`).
17
+ ``live`` rows are streamed by :meth:`watch_ohlcv` in order; the optional 7th
18
+ element is the ``is_closed`` flag (default ``True``). Timestamps are Unix
19
+ seconds and must lie in the past relative to the run's wall clock so the
20
+ child's warmup-horizon drop keeps them.
21
+
22
+ Determinism contract: this is a *deterministic closed-window replay*, not a
23
+ deterministic forming-update replay. :meth:`watch_ohlcv` emits the fixture
24
+ bars eagerly (one per call, no pacing); the security child rations closed
25
+ intrabars per LTF period from its own buffer, so closed-window output is
26
+ deterministic. The developing/forming tail is not lockstep-paced across the
27
+ process boundary (the streamer keeps only the latest forming snapshot), so
28
+ bar-by-bar developing-tail evolution is covered by the in-process collector
29
+ unit test, not by a replay run.
30
+ """
31
+
32
+ import asyncio
33
+ import json
34
+ from dataclasses import dataclass
35
+ from datetime import datetime, time
36
+ from typing import Callable
37
+
38
+ from pynecore.core.plugin import override
39
+ from pynecore.core.plugin.live_provider import LiveProviderConfig, LiveProviderPlugin
40
+ from pynecore.core.syminfo import SymInfo, SymInfoInterval, SymInfoSession
41
+ from pynecore.types.ohlcv import OHLCV
42
+
43
+
44
+ @dataclass
45
+ class ReplayConfig(LiveProviderConfig):
46
+ """Config for :class:`ReplayProvider`.
47
+
48
+ :ivar fixture_path: Absolute path to the JSON fixture file (warmup +
49
+ live bars). Inherits ``symbol_map`` from
50
+ :class:`LiveProviderConfig`.
51
+ """
52
+
53
+ fixture_path: str = ""
54
+
55
+
56
+ class ReplayProvider(LiveProviderPlugin[ReplayConfig]):
57
+ """Fixture-fed live provider that replays pre-recorded bars deterministically."""
58
+
59
+ Config = ReplayConfig
60
+
61
+ feed_timeout_bars: int | None = None
62
+ """No feed-staleness watchdog: post-fixture silence is the end of the
63
+ recording, not a dead feed, so a reconnect must never be forced."""
64
+
65
+ def __init__(self, *, symbol: str | None = None, timeframe: str | None = None,
66
+ ohlcv_dir=None, config: ReplayConfig | None = None):
67
+ super().__init__(symbol=symbol, timeframe=timeframe, ohlcv_dir=ohlcv_dir,
68
+ config=config)
69
+ self._live_bars: list[OHLCV] = []
70
+ self._live_idx: int = 0
71
+ self._exhausted: asyncio.Event | None = None
72
+ self._connected: bool = False
73
+
74
+ # --- Timeframe converters (fixtures already use TradingView format) ---
75
+
76
+ @classmethod
77
+ @override
78
+ def to_tradingview_timeframe(cls, timeframe: str) -> str:
79
+ return timeframe
80
+
81
+ @classmethod
82
+ @override
83
+ def to_exchange_timeframe(cls, timeframe: str) -> str:
84
+ return timeframe
85
+
86
+ # --- Symbol metadata ---
87
+
88
+ @override
89
+ def get_list_of_symbols(self, *args, **kwargs) -> list[str]:
90
+ return [self.symbol] if self.symbol else []
91
+
92
+ @override
93
+ def update_symbol_info(self) -> SymInfo:
94
+ """Minimal 24/7 crypto syminfo.
95
+
96
+ The e2e test normally passes an explicit ``PluginSymbol.syminfo`` so
97
+ the child never calls this; it is the abstract-method fallback.
98
+ """
99
+ opening_hours = [SymInfoInterval(day=i, start=time(0, 0), end=time(23, 59, 59))
100
+ for i in range(7)]
101
+ session_starts = [SymInfoSession(day=i, time=time(0, 0)) for i in range(7)]
102
+ session_ends = [SymInfoSession(day=i, time=time(23, 59, 59)) for i in range(7)]
103
+ return SymInfo(
104
+ prefix="REPLAY",
105
+ description="Replay Symbol",
106
+ ticker=self.symbol or "REPLAY",
107
+ currency="USD",
108
+ basecurrency="REPLAY",
109
+ period=self.timeframe or "1",
110
+ type="crypto",
111
+ mintick=0.01,
112
+ pricescale=100,
113
+ minmove=1,
114
+ pointvalue=1,
115
+ mincontract=0.0001,
116
+ timezone=self.timezone,
117
+ volumetype="base",
118
+ taker_fee=0.0,
119
+ maker_fee=0.0,
120
+ opening_hours=opening_hours,
121
+ session_starts=session_starts,
122
+ session_ends=session_ends,
123
+ )
124
+
125
+ # --- Fixture loading ---
126
+
127
+ def _load_fixture(self) -> dict:
128
+ assert self.config is not None and self.config.fixture_path, \
129
+ "ReplayProvider requires config.fixture_path"
130
+ with open(self.config.fixture_path, encoding="utf-8") as fh:
131
+ return json.load(fh)
132
+
133
+ @override
134
+ def download_ohlcv(self, time_from: datetime, time_to: datetime,
135
+ on_progress: Callable[[datetime], None] | None = None,
136
+ limit: int | None = None, with_extra: bool = False):
137
+ """Replay the fixture's warmup bars (all closed) into the in-memory
138
+ capture used by the security subprocess. The whole recorded warmup is
139
+ authoritative; ``time_from``/``time_to`` are not used to filter."""
140
+ fixture = self._load_fixture()
141
+ for row in fixture.get("warmup", []):
142
+ ts, o, h, low, c, v = row[:6]
143
+ self.save_ohlcv_data(OHLCV(
144
+ timestamp=int(ts), open=float(o), high=float(h),
145
+ low=float(low), close=float(c), volume=float(v),
146
+ ))
147
+ if on_progress:
148
+ on_progress(time_to)
149
+
150
+ # --- Live streaming ---
151
+
152
+ @override
153
+ async def connect(self) -> None:
154
+ fixture = self._load_fixture()
155
+ self._live_bars = [
156
+ OHLCV(
157
+ timestamp=int(r[0]), open=float(r[1]), high=float(r[2]),
158
+ low=float(r[3]), close=float(r[4]), volume=float(r[5]),
159
+ is_closed=bool(r[6]) if len(r) > 6 else True,
160
+ )
161
+ for r in fixture.get("live", [])
162
+ ]
163
+ self._live_idx = 0
164
+ self._exhausted = asyncio.Event()
165
+ self._connected = True
166
+
167
+ @override
168
+ async def disconnect(self) -> None:
169
+ self._connected = False
170
+
171
+ @property
172
+ @override
173
+ def is_connected(self) -> bool:
174
+ return self._connected
175
+
176
+ @override
177
+ async def watch_ohlcv(self, symbol: str, timeframe: str) -> OHLCV:
178
+ if self._live_idx < len(self._live_bars):
179
+ bar = self._live_bars[self._live_idx]
180
+ self._live_idx += 1
181
+ return bar
182
+ # Fixture exhausted: park on a never-set event (event-driven, no sleep,
183
+ # no busy-wait). The streamer's stop() closes the driving generator,
184
+ # which cancels this await cleanly at shutdown.
185
+ assert self._exhausted is not None
186
+ await self._exhausted.wait()
187
+ return self._live_bars[-1]
File without changes
@@ -0,0 +1,498 @@
1
+ """
2
+ PyneCore API client
3
+ """
4
+ from typing import Any
5
+
6
+ import json
7
+ import base64
8
+
9
+ from datetime import datetime
10
+
11
+ from dataclasses import dataclass
12
+
13
+ import urllib.request
14
+ import urllib.parse
15
+ import urllib.error
16
+
17
+
18
+ #
19
+ # API Response Models
20
+ #
21
+
22
+ @dataclass
23
+ class TokenValidationResponse:
24
+ """Response from token validation endpoint."""
25
+ valid: bool
26
+ message: str
27
+ user_id: str | None = None
28
+ token_type: str | None = None
29
+ expiration: datetime | None = None
30
+ expires_at: datetime | None = None
31
+ expires_in: int | None = None
32
+ raw_response: dict[str, Any] | None = None
33
+
34
+
35
+ @dataclass
36
+ class UsageLimits:
37
+ """Usage limits for daily and hourly periods."""
38
+ limit: int
39
+ used: int
40
+ remaining: int
41
+ reset_at: datetime
42
+
43
+
44
+ @dataclass
45
+ class UsageResponse:
46
+ """Response from account usage endpoint."""
47
+ daily: UsageLimits
48
+ hourly: UsageLimits
49
+ api_keys: dict[str, Any]
50
+ raw_response: dict[str, Any] | None = None
51
+
52
+
53
+ @dataclass
54
+ class CompileResponse:
55
+ """Response from script compilation endpoint."""
56
+ success: bool
57
+ compiled_code: str | None = None
58
+ error_message: str | None = None
59
+ error: str | None = None
60
+ validation_errors: list[dict[str, Any]] | None = None
61
+ warnings: list[str] | None = None
62
+ details: list[str] | None = None
63
+ status_code: int | None = None
64
+ raw_response: dict[str, Any] | None = None
65
+
66
+ @property
67
+ def has_validation_errors(self) -> bool:
68
+ """Check if response contains validation errors."""
69
+ return bool(self.validation_errors)
70
+
71
+ @property
72
+ def is_rate_limited(self) -> bool:
73
+ """Check if response indicates rate limiting."""
74
+ return self.status_code == 429
75
+
76
+ @property
77
+ def is_auth_error(self) -> bool:
78
+ """Check if response indicates an authentication error."""
79
+ return self.status_code == 401
80
+
81
+
82
+ #
83
+ # Exceptions
84
+ #
85
+
86
+ class APIError(Exception):
87
+ """Base exception for API-related errors."""
88
+
89
+ def __init__(self, message: str = "", status_code: int | None = None,
90
+ response_data: dict[str, Any] | None = None):
91
+ super().__init__(message)
92
+ self.status_code = status_code
93
+ self.response_data = response_data or {}
94
+
95
+
96
+ class AuthError(APIError):
97
+ """Authentication-related errors (401, invalid token, etc.)."""
98
+ pass
99
+
100
+
101
+ class RateLimitError(APIError):
102
+ """Rate limiting errors (429)."""
103
+
104
+ def __init__(self, message: str, retry_after: int | None = None, **kwargs):
105
+ super().__init__(message, **kwargs)
106
+ self.retry_after = retry_after
107
+
108
+
109
+ class CompilationError(APIError):
110
+ """Compilation-related errors (400, 422)."""
111
+
112
+ def __init__(self, message: str, validation_errors: list | None = None, **kwargs):
113
+ super().__init__(message, **kwargs)
114
+ self.validation_errors = validation_errors or []
115
+
116
+
117
+ class NetworkError(APIError):
118
+ """Network-related errors (timeouts, connection issues)."""
119
+ pass
120
+
121
+
122
+ class ServerError(APIError):
123
+ """Server-side errors (500, 502, etc.)."""
124
+ pass
125
+
126
+
127
+ #
128
+ # API Client
129
+ #
130
+
131
+ class APIClient:
132
+ """
133
+ API Client for interacting with PyneSys API
134
+ """
135
+
136
+ def __init__(self, api_key: str, base_url: str = "https://api.pynesys.io", timeout: int = 30):
137
+ """
138
+ Initialize the API client.
139
+
140
+ :param api_key: PyneSys API key
141
+ :param base_url: Base URL for the API
142
+ :param timeout: Request timeout in seconds
143
+ """
144
+ if api_key is None or not api_key.strip():
145
+ raise ValueError("API key is required")
146
+
147
+ self.api_key = api_key
148
+ self.base_url = base_url.rstrip("/")
149
+ self.timeout = timeout
150
+
151
+ def _make_request(
152
+ self,
153
+ method: str,
154
+ endpoint: str,
155
+ data: dict[str, Any] | None = None,
156
+ headers: dict[str, str] | None = None
157
+ ) -> urllib.request.Request:
158
+ """
159
+ Create a urllib request object.
160
+
161
+ :param method: HTTP method (GET, POST, etc.)
162
+ :param endpoint: API endpoint
163
+ :param data: Request data
164
+ :param headers: Additional headers
165
+ :return: Configured request object
166
+ """
167
+ url = f"{self.base_url}/{endpoint}"
168
+
169
+ # Default headers
170
+ req_headers = {
171
+ "Authorization": f"Bearer {self.api_key}",
172
+ "User-Agent": "PyneCore-API-Client",
173
+ }
174
+
175
+ if headers:
176
+ req_headers.update(headers)
177
+
178
+ # Handle data encoding
179
+ encoded_data = None
180
+ if data and method != "GET":
181
+ if "Content-Type" in req_headers and "json" in req_headers["Content-Type"]:
182
+ encoded_data = json.dumps(data).encode('utf-8')
183
+ else:
184
+ encoded_data = urllib.parse.urlencode(data).encode('utf-8')
185
+ elif data and method == "GET":
186
+ # For GET requests, add data as query parameters
187
+ query_string = urllib.parse.urlencode(data)
188
+ url = f"{url}?{query_string}"
189
+
190
+ request = urllib.request.Request(
191
+ url,
192
+ data=encoded_data,
193
+ headers=req_headers,
194
+ method=method
195
+ )
196
+
197
+ return request
198
+
199
+ def verify_token(self) -> TokenValidationResponse:
200
+ """
201
+ Verify API token validity.
202
+
203
+ :return: TokenValidationResponse with validation details
204
+ :raises AuthError: If token is invalid
205
+ :raises NetworkError: If network request fails
206
+ :raises APIError: For other API errors
207
+ """
208
+ try:
209
+ request = self._make_request(
210
+ "GET",
211
+ "auth/verify-token",
212
+ data={"token": self.api_key}
213
+ )
214
+
215
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
216
+ data = json.loads(response.read().decode('utf-8'))
217
+ return TokenValidationResponse(
218
+ valid=data.get("valid", False),
219
+ message=data.get("message", ""),
220
+ user_id=data.get("user_id"),
221
+ token_type=data.get("token_type"),
222
+ expiration=(datetime.fromisoformat(data["expiration"].replace("Z", "+00:00"))
223
+ if data.get("expiration") else None),
224
+ expires_at=(datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00"))
225
+ if data.get("expires_at") else None),
226
+ expires_in=data.get("expires_in"),
227
+ raw_response=data,
228
+ )
229
+
230
+ except urllib.error.HTTPError as e:
231
+ self._handle_http_error(e)
232
+ except urllib.error.URLError as e:
233
+ raise NetworkError(f"Network error during token verification: {e}")
234
+ except Exception as e:
235
+ if not isinstance(e, APIError):
236
+ raise APIError(f"Unexpected error during token verification: {e}")
237
+ else:
238
+ raise
239
+
240
+ raise APIError("Unexpected error during token verification.")
241
+
242
+ def verify_token_local(self) -> TokenValidationResponse:
243
+ """
244
+ Verify JWT token locally without server request.
245
+
246
+ :return: TokenValidationResponse with validation details
247
+ :raises AuthError: If token format is invalid or expired
248
+ """
249
+ try:
250
+ # JWT tokens have format: header.payload.signature
251
+ parts = self.api_key.split('.')
252
+ if len(parts) != 3:
253
+ return TokenValidationResponse(
254
+ valid=False,
255
+ message="Invalid JWT format: must have 3 parts separated by dots"
256
+ )
257
+
258
+ header_b64, payload_b64, signature_b64 = parts
259
+
260
+ # Decode header
261
+ try:
262
+ # Add padding if needed
263
+ header_b64 += '=' * (4 - len(header_b64) % 4)
264
+ header_data = json.loads(base64.urlsafe_b64decode(header_b64).decode('utf-8'))
265
+ except (ValueError, json.JSONDecodeError):
266
+ return TokenValidationResponse(
267
+ valid=False,
268
+ message="Invalid JWT header format"
269
+ )
270
+
271
+ # Decode payload
272
+ try:
273
+ # Add padding if needed
274
+ payload_b64 += '=' * (4 - len(payload_b64) % 4)
275
+ payload_data = json.loads(base64.urlsafe_b64decode(payload_b64).decode('utf-8'))
276
+ except (ValueError, json.JSONDecodeError):
277
+ return TokenValidationResponse(
278
+ valid=False,
279
+ message="Invalid JWT payload format"
280
+ )
281
+
282
+ # Check expiration - try both 'exp' (standard) and 'e' (custom format)
283
+ exp = payload_data.get('exp') or payload_data.get('e')
284
+ if exp:
285
+ exp_time = datetime.fromtimestamp(exp)
286
+ if datetime.now() >= exp_time:
287
+ return TokenValidationResponse(
288
+ valid=False,
289
+ message="Token has expired",
290
+ expiration=exp_time,
291
+ expires_at=exp_time
292
+ )
293
+
294
+ # Extract user info
295
+ user_id = payload_data.get('s') # Based on the image, 's' contains user ID
296
+
297
+ return TokenValidationResponse(
298
+ valid=True,
299
+ message="Token is valid",
300
+ user_id=user_id,
301
+ token_type=header_data.get('typ', 'JWT'),
302
+ expiration=datetime.fromtimestamp(exp) if exp else None,
303
+ expires_at=datetime.fromtimestamp(exp) if exp else None,
304
+ raw_response={
305
+ 'header': header_data,
306
+ 'payload': payload_data
307
+ }
308
+ )
309
+
310
+ except Exception as e:
311
+ return TokenValidationResponse(
312
+ valid=False,
313
+ message=f"Token validation error: {str(e)}"
314
+ )
315
+
316
+ def get_usage(self) -> UsageResponse:
317
+ """
318
+ Get current usage statistics and limits for the authenticated user.
319
+
320
+ :return: UsageResponse with usage details
321
+ :raises AuthError: If authentication fails
322
+ :raises NetworkError: If network request fails
323
+ :raises APIError: For other API errors
324
+ """
325
+ try:
326
+ request = self._make_request("GET", "account/usage")
327
+
328
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
329
+ data = json.loads(response.read().decode('utf-8'))
330
+
331
+ # Parse daily usage
332
+ daily_data = data["daily"]
333
+ daily = UsageLimits(
334
+ limit=daily_data["limit"],
335
+ used=daily_data["used"],
336
+ remaining=daily_data["remaining"],
337
+ reset_at=datetime.fromisoformat(daily_data["reset_at"].replace("Z", "+00:00"))
338
+ )
339
+
340
+ # Parse hourly usage
341
+ hourly_data = data["hourly"]
342
+ hourly = UsageLimits(
343
+ limit=hourly_data["limit"],
344
+ used=hourly_data["used"],
345
+ remaining=hourly_data["remaining"],
346
+ reset_at=datetime.fromisoformat(hourly_data["reset_at"].replace("Z", "+00:00"))
347
+ )
348
+
349
+ return UsageResponse(
350
+ daily=daily,
351
+ hourly=hourly,
352
+ api_keys=data.get("api_keys", {}),
353
+ raw_response=data
354
+ )
355
+
356
+ except urllib.error.HTTPError as e:
357
+ self._handle_http_error(e)
358
+ except urllib.error.URLError as e:
359
+ raise NetworkError(f"Network error during usage retrieval: {e}")
360
+ except Exception as e:
361
+ if not isinstance(e, APIError):
362
+ raise APIError(f"Unexpected error during usage retrieval: {e}")
363
+ else:
364
+ raise
365
+
366
+ raise APIError("Unexpected error during usage retrieval.")
367
+
368
+ def compile_script(
369
+ self,
370
+ script: str,
371
+ strict: bool = False
372
+ ) -> CompileResponse:
373
+ """
374
+ Compile Pine Script to Python via API.
375
+
376
+ :param script: Pine Script code to compile
377
+ :param strict: Whether to use strict compilation mode
378
+ :return: CompileResponse with compiled code or error details
379
+ :raises AuthError: If authentication fails
380
+ :raises RateLimitError: If rate limit is exceeded
381
+ :raises CompilationError: If compilation fails
382
+ :raises NetworkError: If network request fails
383
+ :raises APIError: For other API errors
384
+ """
385
+ try:
386
+ # Prepare form data
387
+ data = {
388
+ "script": script,
389
+ "strict": str(strict).lower()
390
+ }
391
+
392
+ request = self._make_request(
393
+ "POST",
394
+ "compiler/compile",
395
+ data=data,
396
+ headers={"Content-Type": "application/x-www-form-urlencoded"}
397
+ )
398
+
399
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
400
+ # Success - return compiled code
401
+ compiled_code = response.read().decode('utf-8')
402
+ return CompileResponse(
403
+ success=True,
404
+ compiled_code=compiled_code,
405
+ status_code=200
406
+ )
407
+
408
+ except urllib.error.HTTPError as e:
409
+ # Handle error responses
410
+ return self._handle_compile_http_error(e)
411
+ except urllib.error.URLError as e:
412
+ raise NetworkError(f"Network error during compilation: {e}")
413
+ except Exception as e:
414
+ if not isinstance(e, APIError):
415
+ raise APIError(f"Unexpected error during compilation: {e}")
416
+ else:
417
+ raise
418
+
419
+ @staticmethod
420
+ def _handle_http_error(error: urllib.error.HTTPError, message: str = None) -> None:
421
+ """
422
+ Handle HTTP error responses.
423
+
424
+ :param error: HTTPError object
425
+ :param message: Optional pre-extracted error message
426
+ :raises: Appropriate exception based on status code
427
+ """
428
+ status_code = error.code
429
+
430
+ if message is None:
431
+ try:
432
+ error_content = error.read().decode('utf-8')
433
+ error_data = json.loads(error_content)
434
+ message = error_data.get("message", error_content)
435
+ except (json.JSONDecodeError, ValueError):
436
+ message = error.reason or f"HTTP {status_code} error"
437
+
438
+ if status_code == 401:
439
+ raise AuthError(message, status_code=status_code)
440
+ elif status_code == 429:
441
+ retry_after = error.headers.get("Retry-After")
442
+ raise RateLimitError(
443
+ message,
444
+ status_code=status_code,
445
+ retry_after=int(retry_after) if retry_after else None
446
+ )
447
+ elif status_code >= 500:
448
+ raise ServerError(message, status_code=status_code)
449
+ else:
450
+ raise APIError(message, status_code=status_code)
451
+
452
+ def _handle_compile_http_error(self, error: urllib.error.HTTPError) -> CompileResponse:
453
+ """
454
+ Handle compilation error responses.
455
+
456
+ :param error: HTTPError object
457
+ :return: CompileResponse with error details
458
+ :raises CompilationError: For compilation-related errors (422)
459
+ :raises: Other exceptions for authentication, rate limiting, etc.
460
+ """
461
+ status_code = error.code
462
+
463
+ try:
464
+ error_content = error.read().decode('utf-8')
465
+ error_data = json.loads(error_content)
466
+ except (json.JSONDecodeError, ValueError):
467
+ error_data = {}
468
+ error_content = error.reason or f"HTTP {status_code} error"
469
+
470
+ # Extract error message
471
+ if "detail" in error_data and isinstance(error_data["detail"], list):
472
+ # Validation error format (422)
473
+ validation_errors = error_data["detail"]
474
+ error_message = "Validation errors occurred"
475
+ elif "detail" in error_data and isinstance(error_data["detail"], dict):
476
+ # Structured error format (400) - pass the complete JSON for parsing
477
+ validation_errors = None
478
+ error_message = error_content # Pass the full JSON response
479
+ else:
480
+ validation_errors = None
481
+ error_message = error_data.get("message", error_content)
482
+
483
+ # For compilation errors (422), raise CompilationError
484
+ if status_code == 422:
485
+ raise CompilationError(error_message, status_code=status_code, validation_errors=validation_errors)
486
+
487
+ # For other errors, use the general error handler with the extracted message
488
+ else:
489
+ self._handle_http_error(error, error_message)
490
+
491
+ # This should never be reached
492
+ return CompileResponse(
493
+ success=False,
494
+ error_message=error_message,
495
+ validation_errors=validation_errors,
496
+ status_code=status_code,
497
+ raw_response=error_data
498
+ )