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,372 @@
|
|
|
1
|
+
"""Scenario execution over the real broker engine and SQLite store."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import socket
|
|
5
|
+
import tempfile
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pynecore import lib
|
|
12
|
+
from pynecore.core.broker.position import BrokerPosition
|
|
13
|
+
from pynecore.core.broker.run_identity import RunIdentity
|
|
14
|
+
from pynecore.core.broker.storage import BrokerStore, RunContext
|
|
15
|
+
from pynecore.core.broker.sync_engine import OrderSyncEngine
|
|
16
|
+
from pynecore.lib.strategy import Order, _order_type_close, _order_type_entry, oca
|
|
17
|
+
|
|
18
|
+
from .model import Scenario, ScenarioInvariantError, ScenarioResult, Step, VenueProfile
|
|
19
|
+
from .scheduler import DeterministicScheduler
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class RunRuntime:
|
|
24
|
+
"""Resources belonging to one logical bot run."""
|
|
25
|
+
|
|
26
|
+
name: str
|
|
27
|
+
store: BrokerStore
|
|
28
|
+
store_ctx: RunContext
|
|
29
|
+
broker: Any
|
|
30
|
+
position: BrokerPosition
|
|
31
|
+
engine: OrderSyncEngine
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _NetworkGuard:
|
|
35
|
+
def __enter__(self):
|
|
36
|
+
self._create_connection = socket.create_connection
|
|
37
|
+
self._connect = socket.socket.connect
|
|
38
|
+
self._connect_ex = socket.socket.connect_ex
|
|
39
|
+
|
|
40
|
+
def blocked(*_args: Any, **_kwargs: Any):
|
|
41
|
+
raise RuntimeError("offline broker lab blocked a network attempt")
|
|
42
|
+
|
|
43
|
+
setattr(socket, "create_connection", blocked)
|
|
44
|
+
setattr(socket.socket, "connect", blocked)
|
|
45
|
+
setattr(socket.socket, "connect_ex", blocked)
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
49
|
+
setattr(socket, "create_connection", self._create_connection)
|
|
50
|
+
setattr(socket.socket, "connect", self._connect)
|
|
51
|
+
setattr(socket.socket, "connect_ex", self._connect_ex)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ScenarioRunner:
|
|
55
|
+
"""Execute, check, and minimize deterministic broker scenarios."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, *, artifact_root: Path | None = None) -> None:
|
|
58
|
+
self.artifact_root = artifact_root
|
|
59
|
+
self.profile: VenueProfile
|
|
60
|
+
self.runs: dict[str, RunRuntime] = {}
|
|
61
|
+
self.now_ms = 1_700_000_000_000
|
|
62
|
+
self.scheduler = DeterministicScheduler(self.now_ms)
|
|
63
|
+
self._tmp: tempfile.TemporaryDirectory[str] | None = None
|
|
64
|
+
self._root: Path | None = None
|
|
65
|
+
self._script_sentinel = object()
|
|
66
|
+
self._old_script: Any = self._script_sentinel
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def workdir(self) -> Path:
|
|
70
|
+
"""Return the isolated filesystem root of the active scenario."""
|
|
71
|
+
if self._root is None:
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
"scenario workdir is only available while a scenario is running"
|
|
74
|
+
)
|
|
75
|
+
return self._root
|
|
76
|
+
|
|
77
|
+
def run(self, scenario: Scenario, *, minimize: bool = True) -> ScenarioResult:
|
|
78
|
+
result = self._execute(scenario)
|
|
79
|
+
if result.passed or not minimize or len(scenario.steps) < 2:
|
|
80
|
+
return result
|
|
81
|
+
target = (result.violation or "").split(":", 1)[0]
|
|
82
|
+
steps = list(scenario.steps)
|
|
83
|
+
changed = True
|
|
84
|
+
while changed:
|
|
85
|
+
changed = False
|
|
86
|
+
for index in range(len(steps)):
|
|
87
|
+
candidate = steps[:index] + steps[index + 1 :]
|
|
88
|
+
if not candidate:
|
|
89
|
+
continue
|
|
90
|
+
trial = self._execute(
|
|
91
|
+
Scenario(
|
|
92
|
+
name=scenario.name,
|
|
93
|
+
profile_factory=scenario.profile_factory,
|
|
94
|
+
steps=tuple(candidate),
|
|
95
|
+
runs=scenario.runs,
|
|
96
|
+
seed=scenario.seed,
|
|
97
|
+
tags=scenario.tags,
|
|
98
|
+
expected_violation=scenario.expected_violation,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
if (
|
|
102
|
+
not trial.passed
|
|
103
|
+
and (trial.violation or "").split(":", 1)[0] == target
|
|
104
|
+
):
|
|
105
|
+
steps = candidate
|
|
106
|
+
changed = True
|
|
107
|
+
break
|
|
108
|
+
return ScenarioResult(
|
|
109
|
+
name=result.name,
|
|
110
|
+
passed=False,
|
|
111
|
+
seed=result.seed,
|
|
112
|
+
executed_steps=result.executed_steps,
|
|
113
|
+
violation=result.violation,
|
|
114
|
+
minimized_steps=tuple(steps),
|
|
115
|
+
artifact_dir=result.artifact_dir,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _execute(self, scenario: Scenario) -> ScenarioResult:
|
|
119
|
+
executed: list[Step] = []
|
|
120
|
+
violation: str | None = None
|
|
121
|
+
artifact_dir: Path | None = None
|
|
122
|
+
try:
|
|
123
|
+
self._start(scenario)
|
|
124
|
+
artifact_dir = self._root if self.artifact_root is not None else None
|
|
125
|
+
with _NetworkGuard():
|
|
126
|
+
for step in scenario.steps:
|
|
127
|
+
self.apply(step)
|
|
128
|
+
executed.append(step)
|
|
129
|
+
if step.check_invariants:
|
|
130
|
+
self.check_invariants()
|
|
131
|
+
except Exception as exc:
|
|
132
|
+
violation = f"{type(exc).__name__}: {exc}"
|
|
133
|
+
finally:
|
|
134
|
+
self.close()
|
|
135
|
+
if scenario.expected_violation is not None:
|
|
136
|
+
if violation is None:
|
|
137
|
+
violation = (
|
|
138
|
+
f"expected violation was not raised: {scenario.expected_violation}"
|
|
139
|
+
)
|
|
140
|
+
elif scenario.expected_violation in violation:
|
|
141
|
+
violation = None
|
|
142
|
+
return ScenarioResult(
|
|
143
|
+
name=scenario.name,
|
|
144
|
+
passed=violation is None,
|
|
145
|
+
seed=scenario.seed,
|
|
146
|
+
executed_steps=tuple(executed),
|
|
147
|
+
violation=violation,
|
|
148
|
+
artifact_dir=artifact_dir,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def _start(self, scenario: Scenario) -> None:
|
|
152
|
+
self.profile = scenario.profile_factory()
|
|
153
|
+
self.runs = {}
|
|
154
|
+
self.now_ms = 1_700_000_000_000
|
|
155
|
+
self.scheduler = DeterministicScheduler(self.now_ms)
|
|
156
|
+
self._old_script = getattr(lib, "_script", self._script_sentinel)
|
|
157
|
+
lib._script = SimpleNamespace(initial_capital=1_000_000.0)
|
|
158
|
+
if self.artifact_root is None:
|
|
159
|
+
self._tmp = tempfile.TemporaryDirectory(prefix="pyne-broker-lab-")
|
|
160
|
+
self._root = Path(self._tmp.name)
|
|
161
|
+
else:
|
|
162
|
+
self._root = self.artifact_root / scenario.name
|
|
163
|
+
self._root.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
for run_name in scenario.runs:
|
|
165
|
+
self._open_run(run_name)
|
|
166
|
+
|
|
167
|
+
def _open_run(self, run_name: str) -> None:
|
|
168
|
+
assert self._root is not None
|
|
169
|
+
store = BrokerStore(
|
|
170
|
+
self._root / "broker.sqlite",
|
|
171
|
+
plugin_name=self.profile.plugin_name,
|
|
172
|
+
)
|
|
173
|
+
identity = RunIdentity(
|
|
174
|
+
strategy_id="broker_lab",
|
|
175
|
+
symbol=self.profile.symbol,
|
|
176
|
+
timeframe=self.profile.timeframe,
|
|
177
|
+
account_id=self.profile.account_id,
|
|
178
|
+
label=run_name,
|
|
179
|
+
)
|
|
180
|
+
store_ctx = store.open_run(identity, script_source="// offline broker lab")
|
|
181
|
+
broker = self.profile.create_broker(run_name, store_ctx)
|
|
182
|
+
position = BrokerPosition()
|
|
183
|
+
engine = OrderSyncEngine(
|
|
184
|
+
broker=broker,
|
|
185
|
+
position=position,
|
|
186
|
+
symbol=self.profile.symbol,
|
|
187
|
+
run_tag=store_ctx.run_tag,
|
|
188
|
+
mintick=0.01,
|
|
189
|
+
store_ctx=store_ctx,
|
|
190
|
+
reconcile_every_n_syncs=1,
|
|
191
|
+
)
|
|
192
|
+
engine.reconcile()
|
|
193
|
+
self.runs[run_name] = RunRuntime(
|
|
194
|
+
name=run_name,
|
|
195
|
+
store=store,
|
|
196
|
+
store_ctx=store_ctx,
|
|
197
|
+
broker=broker,
|
|
198
|
+
position=position,
|
|
199
|
+
engine=engine,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def apply(self, step: Step) -> None:
|
|
203
|
+
if step.run not in self.runs:
|
|
204
|
+
raise KeyError(f"unknown run {step.run!r}")
|
|
205
|
+
runtime = self.runs[step.run]
|
|
206
|
+
values = step.values
|
|
207
|
+
if step.kind == "entry":
|
|
208
|
+
pine_id = str(values.get("id", "L"))
|
|
209
|
+
side = str(values.get("side", "buy"))
|
|
210
|
+
qty = float(values.get("qty", 1.0))
|
|
211
|
+
runtime.position.entry_orders[pine_id] = Order(
|
|
212
|
+
pine_id,
|
|
213
|
+
qty if side == "buy" else -qty,
|
|
214
|
+
order_type=_order_type_entry,
|
|
215
|
+
limit=values.get("limit"),
|
|
216
|
+
stop=values.get("stop"),
|
|
217
|
+
oca_name=values.get("oca_name"),
|
|
218
|
+
oca_type=getattr(oca, str(values.get("oca_type", "none"))),
|
|
219
|
+
)
|
|
220
|
+
elif step.kind == "amend":
|
|
221
|
+
pine_id = str(values.get("id", "L"))
|
|
222
|
+
old = runtime.position.entry_orders.get(pine_id)
|
|
223
|
+
if old is None:
|
|
224
|
+
raise ValueError(f"cannot amend unknown entry {pine_id!r}")
|
|
225
|
+
runtime.position.entry_orders[pine_id] = Order(
|
|
226
|
+
pine_id,
|
|
227
|
+
old.size,
|
|
228
|
+
order_type=_order_type_entry,
|
|
229
|
+
limit=values.get("limit", old.limit),
|
|
230
|
+
stop=values.get("stop", old.stop),
|
|
231
|
+
oca_name=values.get("oca_name", old.oca_name),
|
|
232
|
+
oca_type=getattr(oca, str(values.get("oca_type", old.oca_type))),
|
|
233
|
+
)
|
|
234
|
+
elif step.kind == "amend_exit":
|
|
235
|
+
exit_id = str(values.get("id", "X"))
|
|
236
|
+
from_entry = values.get("from_entry")
|
|
237
|
+
key = (exit_id, from_entry)
|
|
238
|
+
old = runtime.position.exit_orders.get(key)
|
|
239
|
+
if old is None:
|
|
240
|
+
raise ValueError(f"cannot amend unknown exit {key!r}")
|
|
241
|
+
order = Order(
|
|
242
|
+
from_entry,
|
|
243
|
+
old.size,
|
|
244
|
+
order_type=_order_type_close,
|
|
245
|
+
exit_id=exit_id,
|
|
246
|
+
limit=values.get("limit", old.limit),
|
|
247
|
+
stop=values.get("stop", old.stop),
|
|
248
|
+
trail_price=values.get("trail_price", old.trail_price),
|
|
249
|
+
trail_offset=values.get("trail_offset", old.trail_offset),
|
|
250
|
+
)
|
|
251
|
+
order.from_entry_na = from_entry is None
|
|
252
|
+
order.rest_leg = bool(values.get("rest_leg", old.rest_leg))
|
|
253
|
+
runtime.position.exit_orders[key] = order
|
|
254
|
+
elif step.kind in ("exit", "close", "close_percent"):
|
|
255
|
+
is_exit = step.kind == "exit"
|
|
256
|
+
pine_id = str(values.get("id", "X" if is_exit else "L"))
|
|
257
|
+
exit_id = pine_id if is_exit else f"Close entry(s) order {pine_id}"
|
|
258
|
+
from_entry = values.get("from_entry")
|
|
259
|
+
side = str(values.get("side", "sell"))
|
|
260
|
+
if step.kind == "close_percent":
|
|
261
|
+
percent = float(values["percent"])
|
|
262
|
+
if percent <= 0.0 or percent > 100.0:
|
|
263
|
+
raise ValueError("close percent must be in (0, 100]")
|
|
264
|
+
qty = abs(runtime.position.size) * percent / 100.0
|
|
265
|
+
else:
|
|
266
|
+
qty = float(values.get("qty", abs(runtime.position.size) or 1.0))
|
|
267
|
+
order = Order(
|
|
268
|
+
from_entry if is_exit else pine_id,
|
|
269
|
+
qty if side == "buy" else -qty,
|
|
270
|
+
order_type=_order_type_close,
|
|
271
|
+
exit_id=exit_id,
|
|
272
|
+
limit=values.get("limit"),
|
|
273
|
+
stop=values.get("stop"),
|
|
274
|
+
trail_price=values.get("trail_price"),
|
|
275
|
+
trail_offset=values.get("trail_offset"),
|
|
276
|
+
)
|
|
277
|
+
order.from_entry_na = from_entry is None
|
|
278
|
+
order.rest_leg = bool(values.get("rest_leg", False))
|
|
279
|
+
key = (exit_id, from_entry if is_exit else pine_id)
|
|
280
|
+
runtime.position.exit_orders[key] = order
|
|
281
|
+
elif step.kind == "cancel":
|
|
282
|
+
cancel_id = values.get("id")
|
|
283
|
+
runtime.position.entry_orders.pop(cancel_id, None)
|
|
284
|
+
for key in list(runtime.position.exit_orders):
|
|
285
|
+
if cancel_id in key:
|
|
286
|
+
runtime.position.exit_orders.pop(key)
|
|
287
|
+
elif step.kind == "cancel_all":
|
|
288
|
+
runtime.position.entry_orders.clear()
|
|
289
|
+
runtime.position.exit_orders.clear()
|
|
290
|
+
elif step.kind == "sync":
|
|
291
|
+
self.scheduler.advance(int(values.get("advance_ms", 1_000)))
|
|
292
|
+
self.now_ms = self.scheduler.now_ms
|
|
293
|
+
runtime.engine.sync(
|
|
294
|
+
self.now_ms, last_price=float(values.get("last_price", 100.0))
|
|
295
|
+
)
|
|
296
|
+
elif step.kind == "sync_expect_error":
|
|
297
|
+
self.scheduler.advance(int(values.get("advance_ms", 1_000)))
|
|
298
|
+
self.now_ms = self.scheduler.now_ms
|
|
299
|
+
expected = str(values["type"])
|
|
300
|
+
try:
|
|
301
|
+
runtime.engine.sync(
|
|
302
|
+
self.now_ms,
|
|
303
|
+
last_price=float(values.get("last_price", 100.0)),
|
|
304
|
+
)
|
|
305
|
+
except Exception as exc:
|
|
306
|
+
if type(exc).__name__ != expected:
|
|
307
|
+
raise AssertionError(
|
|
308
|
+
f"expected sync error {expected}, got {type(exc).__name__}: {exc}"
|
|
309
|
+
) from exc
|
|
310
|
+
else:
|
|
311
|
+
raise AssertionError(
|
|
312
|
+
f"expected sync error {expected}, but sync succeeded"
|
|
313
|
+
)
|
|
314
|
+
elif step.kind == "advance":
|
|
315
|
+
self.scheduler.advance(int(values.get("ms", 1_000)))
|
|
316
|
+
self.now_ms = self.scheduler.now_ms
|
|
317
|
+
elif step.kind == "pump_watch":
|
|
318
|
+
|
|
319
|
+
async def pump_one() -> None:
|
|
320
|
+
stream = runtime.broker.watch_orders()
|
|
321
|
+
timeout_seconds = float(values.get("timeout_seconds", 1.0))
|
|
322
|
+
try:
|
|
323
|
+
event = await asyncio.wait_for(
|
|
324
|
+
stream.__anext__(),
|
|
325
|
+
timeout=timeout_seconds,
|
|
326
|
+
)
|
|
327
|
+
except TimeoutError as exc:
|
|
328
|
+
raise AssertionError(
|
|
329
|
+
"pump_watch received no broker event within "
|
|
330
|
+
f"{timeout_seconds:g}s"
|
|
331
|
+
) from exc
|
|
332
|
+
finally:
|
|
333
|
+
await stream.aclose()
|
|
334
|
+
runtime.engine.on_order_event(event)
|
|
335
|
+
|
|
336
|
+
asyncio.run(pump_one())
|
|
337
|
+
runtime.engine.apply_async_events()
|
|
338
|
+
elif step.kind == "restart":
|
|
339
|
+
runtime.store_ctx.close()
|
|
340
|
+
runtime.store.close()
|
|
341
|
+
del self.runs[step.run]
|
|
342
|
+
self._open_run(step.run)
|
|
343
|
+
self.runs[step.run].engine.settle_restart_state(self.now_ms)
|
|
344
|
+
elif step.kind == "shutdown":
|
|
345
|
+
runtime.store_ctx.close()
|
|
346
|
+
runtime.store.close()
|
|
347
|
+
elif not self.profile.handle_step(self, step):
|
|
348
|
+
raise ValueError(f"unsupported scenario step {step.kind!r}")
|
|
349
|
+
|
|
350
|
+
def check_invariants(self) -> None:
|
|
351
|
+
violations = list(self.profile.check_invariants(self))
|
|
352
|
+
if violations:
|
|
353
|
+
raise ScenarioInvariantError("; ".join(violations))
|
|
354
|
+
|
|
355
|
+
def close(self) -> None:
|
|
356
|
+
for runtime in list(self.runs.values()):
|
|
357
|
+
try:
|
|
358
|
+
runtime.store_ctx.close()
|
|
359
|
+
except Exception:
|
|
360
|
+
pass
|
|
361
|
+
runtime.store.close()
|
|
362
|
+
self.runs.clear()
|
|
363
|
+
if hasattr(self, "profile"):
|
|
364
|
+
self.profile.close()
|
|
365
|
+
if self._old_script is self._script_sentinel:
|
|
366
|
+
if hasattr(lib, "_script"):
|
|
367
|
+
delattr(lib, "_script")
|
|
368
|
+
else:
|
|
369
|
+
lib._script = self._old_script
|
|
370
|
+
if self._tmp is not None:
|
|
371
|
+
self._tmp.cleanup()
|
|
372
|
+
self._tmp = None
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Deterministic virtual-time scheduling for offline broker scenarios."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from heapq import heappop, heappush
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(order=True)
|
|
9
|
+
class ScheduledEvent:
|
|
10
|
+
"""One callback ordered by virtual deadline and insertion sequence."""
|
|
11
|
+
|
|
12
|
+
deadline_ms: int
|
|
13
|
+
sequence: int
|
|
14
|
+
callback: Callable[[], None] = field(compare=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DeterministicScheduler:
|
|
18
|
+
"""Advance callbacks without sleeping or consulting wall-clock time."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, start_ms: int = 1_700_000_000_000) -> None:
|
|
21
|
+
self.now_ms = start_ms
|
|
22
|
+
self._sequence = 0
|
|
23
|
+
self._events: list[ScheduledEvent] = []
|
|
24
|
+
|
|
25
|
+
def schedule(self, delay_ms: int, callback: Callable[[], None]) -> int:
|
|
26
|
+
if delay_ms < 0:
|
|
27
|
+
raise ValueError("delay_ms must not be negative")
|
|
28
|
+
self._sequence += 1
|
|
29
|
+
heappush(
|
|
30
|
+
self._events,
|
|
31
|
+
ScheduledEvent(self.now_ms + delay_ms, self._sequence, callback),
|
|
32
|
+
)
|
|
33
|
+
return self._sequence
|
|
34
|
+
|
|
35
|
+
def advance(self, milliseconds: int) -> None:
|
|
36
|
+
if milliseconds < 0:
|
|
37
|
+
raise ValueError("milliseconds must not be negative")
|
|
38
|
+
target = self.now_ms + milliseconds
|
|
39
|
+
while self._events and self._events[0].deadline_ms <= target:
|
|
40
|
+
event = heappop(self._events)
|
|
41
|
+
self.now_ms = event.deadline_ms
|
|
42
|
+
event.callback()
|
|
43
|
+
self.now_ms = target
|
|
44
|
+
|
|
45
|
+
def run_ready(self) -> None:
|
|
46
|
+
self.advance(0)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def pending(self) -> int:
|
|
50
|
+
return len(self._events)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Isolated subprocess helpers for opt-in CLI lifecycle scenarios."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterator, Mapping, Sequence
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from tempfile import TemporaryDirectory
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class SubprocessResult:
|
|
15
|
+
"""Bounded child-process result captured by the broker lab."""
|
|
16
|
+
|
|
17
|
+
returncode: int
|
|
18
|
+
stdout: str
|
|
19
|
+
stderr: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_subprocess(
|
|
23
|
+
args: Sequence[str],
|
|
24
|
+
*,
|
|
25
|
+
cwd: Path,
|
|
26
|
+
timeout: float = 10.0,
|
|
27
|
+
env: Mapping[str, str] | None = None,
|
|
28
|
+
) -> SubprocessResult:
|
|
29
|
+
"""Run a child with a hard external timeout and captured output."""
|
|
30
|
+
child_env = os.environ.copy()
|
|
31
|
+
if env:
|
|
32
|
+
child_env.update(env)
|
|
33
|
+
try:
|
|
34
|
+
completed = subprocess.run(
|
|
35
|
+
list(args),
|
|
36
|
+
cwd=cwd,
|
|
37
|
+
env=child_env,
|
|
38
|
+
check=False,
|
|
39
|
+
capture_output=True,
|
|
40
|
+
text=True,
|
|
41
|
+
timeout=timeout,
|
|
42
|
+
)
|
|
43
|
+
except subprocess.TimeoutExpired as exc:
|
|
44
|
+
raise TimeoutError(f"broker-lab subprocess exceeded {timeout:.1f}s: {args!r}") from exc
|
|
45
|
+
return SubprocessResult(completed.returncode, completed.stdout, completed.stderr)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@contextmanager
|
|
49
|
+
def temporary_entry_point(
|
|
50
|
+
*,
|
|
51
|
+
group: str,
|
|
52
|
+
name: str,
|
|
53
|
+
target: str,
|
|
54
|
+
) -> Iterator[Path]:
|
|
55
|
+
"""Inject disposable ``.dist-info`` metadata without production hooks."""
|
|
56
|
+
with TemporaryDirectory(prefix="pyne-broker-lab-entrypoint-") as temp:
|
|
57
|
+
root = Path(temp)
|
|
58
|
+
dist_info = root / "offline_broker_lab-0.dist-info"
|
|
59
|
+
dist_info.mkdir()
|
|
60
|
+
(dist_info / "METADATA").write_text(
|
|
61
|
+
"Metadata-Version: 2.1\nName: offline-broker-lab\nVersion: 0\n",
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
)
|
|
64
|
+
(dist_info / "entry_points.txt").write_text(
|
|
65
|
+
f"[{group}]\n{name} = {target}\n",
|
|
66
|
+
encoding="utf-8",
|
|
67
|
+
)
|
|
68
|
+
yield root
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def python_executable() -> str:
|
|
72
|
+
"""Return the interpreter running the opt-in lab."""
|
|
73
|
+
return sys.executable
|
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from typing import Any, cast
|
|
2
|
+
import ast
|
|
3
|
+
import importlib
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
# Pine namespaces whose pynecore module has a different name
|
|
8
|
+
_NAMESPACE_RENAMES = {
|
|
9
|
+
'str': 'string',
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BuiltinShadowTransformer(ast.NodeTransformer):
|
|
14
|
+
"""
|
|
15
|
+
Resolve workdir library imports whose alias shadows a built-in namespace.
|
|
16
|
+
|
|
17
|
+
Pine resolves ``ta.x`` after ``import user/somelib/1 as ta`` against the
|
|
18
|
+
library's exports first and falls back to the built-in ``ta.*`` namespace
|
|
19
|
+
for everything else, so e.g. ``ta.valuewhen`` keeps working when the
|
|
20
|
+
library does not export it. The compiled ``import lib.user.somelib.v1 as
|
|
21
|
+
ta`` would route every access to the library module, so attribute reads
|
|
22
|
+
the library cannot serve are rewritten here to the canonical built-in form
|
|
23
|
+
(``lib.ta.valuewhen``), which the downstream transformers (import
|
|
24
|
+
normalizer, module properties, isolation, series) handle natively.
|
|
25
|
+
|
|
26
|
+
Which members the library serves is runtime knowledge: the library module
|
|
27
|
+
is imported at transform time (the same pattern callee resolution uses in
|
|
28
|
+
function isolation). Its ``__all__`` is the membership test — matching
|
|
29
|
+
TV, where non-exported library names are not reachable through the alias —
|
|
30
|
+
with a ``hasattr`` fallback for hand-written libraries without ``__all__``.
|
|
31
|
+
If the library cannot be imported, the alias is left untouched and the
|
|
32
|
+
script fails at its own import statement exactly as before.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self):
|
|
36
|
+
# Structure: module -> name -> {"type": "property"|"variable"}
|
|
37
|
+
try:
|
|
38
|
+
with open(Path(__file__).parent / "module_properties.json") as f:
|
|
39
|
+
self.registry: dict[str, dict[str, Any]] = json.load(f)
|
|
40
|
+
except (IOError, json.JSONDecodeError) as e:
|
|
41
|
+
raise RuntimeError(f"Failed to load module properties config: {e}")
|
|
42
|
+
|
|
43
|
+
# alias -> (library module, exported names or None, builtin namespace)
|
|
44
|
+
self.aliases: dict[str, tuple[Any, frozenset[str] | None, str]] = {}
|
|
45
|
+
|
|
46
|
+
def visit_Module(self, node: ast.Module) -> ast.Module:
|
|
47
|
+
for stmt in node.body:
|
|
48
|
+
if not isinstance(stmt, ast.Import):
|
|
49
|
+
continue
|
|
50
|
+
for alias in stmt.names:
|
|
51
|
+
if not alias.asname or not alias.name.startswith('lib.'):
|
|
52
|
+
continue
|
|
53
|
+
name = alias.asname
|
|
54
|
+
# --strict compilation suffixes every global binding
|
|
55
|
+
if name.endswith('__global__'):
|
|
56
|
+
name = name[:-len('__global__')]
|
|
57
|
+
# A builtin-named alias is emitted under its canonical image
|
|
58
|
+
# (``import ... as ta`` compiles to ``as ta__ren__``); the
|
|
59
|
+
# image can only come from the bare name (a Pine ``ta__ren__``
|
|
60
|
+
# alias compiles to ``ta__ren____ren__``), so stripping the
|
|
61
|
+
# suffix recovers the source alias.
|
|
62
|
+
if name.endswith('__ren__'):
|
|
63
|
+
name = name[:-len('__ren__')]
|
|
64
|
+
namespace = _NAMESPACE_RENAMES.get(name, name)
|
|
65
|
+
if f'lib.{namespace}' not in self.registry:
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
lib_module = importlib.import_module(alias.name)
|
|
69
|
+
except Exception: # noqa: BLE001 - the script's own import will report it
|
|
70
|
+
continue
|
|
71
|
+
exported = getattr(lib_module, '__all__', None)
|
|
72
|
+
members = frozenset(exported) if exported is not None else None
|
|
73
|
+
self.aliases[alias.asname] = (lib_module, members, namespace)
|
|
74
|
+
|
|
75
|
+
if not self.aliases:
|
|
76
|
+
return node
|
|
77
|
+
return cast(ast.Module, self.generic_visit(node))
|
|
78
|
+
|
|
79
|
+
def _visit_scope(self, node: ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda) -> ast.AST:
|
|
80
|
+
"""Visit a function-like scope, masking aliases shadowed by parameters."""
|
|
81
|
+
args = node.args
|
|
82
|
+
params = {a.arg for a in (*args.posonlyargs, *args.args, *args.kwonlyargs)}
|
|
83
|
+
if args.vararg:
|
|
84
|
+
params.add(args.vararg.arg)
|
|
85
|
+
if args.kwarg:
|
|
86
|
+
params.add(args.kwarg.arg)
|
|
87
|
+
masked = {name: self.aliases.pop(name) for name in params & self.aliases.keys()}
|
|
88
|
+
try:
|
|
89
|
+
return self.generic_visit(node)
|
|
90
|
+
finally:
|
|
91
|
+
self.aliases.update(masked)
|
|
92
|
+
|
|
93
|
+
def visit_FunctionDef(self, node: ast.FunctionDef) -> ast.AST:
|
|
94
|
+
return self._visit_scope(node)
|
|
95
|
+
|
|
96
|
+
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> ast.AST:
|
|
97
|
+
return self._visit_scope(node)
|
|
98
|
+
|
|
99
|
+
def visit_Lambda(self, node: ast.Lambda) -> ast.AST:
|
|
100
|
+
return self._visit_scope(node)
|
|
101
|
+
|
|
102
|
+
def visit_Attribute(self, node: ast.Attribute) -> ast.AST:
|
|
103
|
+
node = cast(ast.Attribute, self.generic_visit(node))
|
|
104
|
+
if not (isinstance(node.ctx, ast.Load) and isinstance(node.value, ast.Name)):
|
|
105
|
+
return node
|
|
106
|
+
entry = self.aliases.get(node.value.id)
|
|
107
|
+
if entry is None:
|
|
108
|
+
return node
|
|
109
|
+
lib_module, members, namespace = entry
|
|
110
|
+
|
|
111
|
+
# The library serves the name -> the access stays on the library
|
|
112
|
+
if node.attr in members if members is not None else hasattr(lib_module, node.attr):
|
|
113
|
+
return node
|
|
114
|
+
|
|
115
|
+
# Built-in namespace fallback -> canonical lib.<namespace>.<attr> form;
|
|
116
|
+
# the nested-key check covers sub-namespaces (e.g. strategy.commission)
|
|
117
|
+
attr = node.attr
|
|
118
|
+
if (attr not in self.registry[f'lib.{namespace}']
|
|
119
|
+
and f'lib.{namespace}.{attr}' not in self.registry
|
|
120
|
+
and attr.endswith('__ren__')):
|
|
121
|
+
# A trigger-named member is emitted under its canonical image
|
|
122
|
+
# (``math.max`` compiles to ``max__ren__``); when the library does
|
|
123
|
+
# not serve it, the built-in namespace knows the bare name only
|
|
124
|
+
attr = attr[:-len('__ren__')]
|
|
125
|
+
if (attr in self.registry[f'lib.{namespace}']
|
|
126
|
+
or f'lib.{namespace}.{attr}' in self.registry):
|
|
127
|
+
return ast.copy_location(
|
|
128
|
+
ast.Attribute(
|
|
129
|
+
value=ast.Attribute(
|
|
130
|
+
value=ast.Name(id='lib', ctx=ast.Load()),
|
|
131
|
+
attr=namespace, ctx=ast.Load()),
|
|
132
|
+
attr=attr, ctx=node.ctx),
|
|
133
|
+
node)
|
|
134
|
+
|
|
135
|
+
# In neither -> leave it to fail at runtime, as before
|
|
136
|
+
return node
|