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,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
|
pynecore/pynesys/api.py
ADDED
|
@@ -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
|
+
)
|