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,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