desic-okx-agent 0.2.0 → 0.3.0

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 (237) hide show
  1. package/README.en.md +353 -0
  2. package/README.md +194 -190
  3. package/dist/account/private-websocket.js +4 -4
  4. package/dist/account/private-websocket.js.map +1 -1
  5. package/dist/account/service.d.ts +12 -1
  6. package/dist/account/service.js +18 -0
  7. package/dist/account/service.js.map +1 -1
  8. package/dist/bars/rate-limiter.d.ts +18 -0
  9. package/dist/bars/rate-limiter.js +84 -0
  10. package/dist/bars/rate-limiter.js.map +1 -0
  11. package/dist/bars/schema.d.ts +36 -0
  12. package/dist/bars/schema.js +134 -0
  13. package/dist/bars/schema.js.map +1 -0
  14. package/dist/bars/service.d.ts +60 -0
  15. package/dist/bars/service.js +120 -0
  16. package/dist/bars/service.js.map +1 -0
  17. package/dist/bars/store.d.ts +105 -0
  18. package/dist/bars/store.js +415 -0
  19. package/dist/bars/store.js.map +1 -0
  20. package/dist/bars/timeframe.d.ts +40 -0
  21. package/dist/bars/timeframe.js +146 -0
  22. package/dist/bars/timeframe.js.map +1 -0
  23. package/dist/bars/types.d.ts +68 -0
  24. package/dist/bars/types.js +13 -0
  25. package/dist/bars/types.js.map +1 -0
  26. package/dist/cli/data-render.d.ts +37 -0
  27. package/dist/cli/data-render.js +143 -0
  28. package/dist/cli/data-render.js.map +1 -0
  29. package/dist/cli/doctor.js +7 -3
  30. package/dist/cli/doctor.js.map +1 -1
  31. package/dist/cli/index.js +758 -27
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/cli/live-render.d.ts +24 -0
  34. package/dist/cli/live-render.js +85 -0
  35. package/dist/cli/live-render.js.map +1 -0
  36. package/dist/cli/range.d.ts +28 -0
  37. package/dist/cli/range.js +63 -0
  38. package/dist/cli/range.js.map +1 -0
  39. package/dist/cli/render.js +3 -0
  40. package/dist/cli/render.js.map +1 -1
  41. package/dist/cli/strategy-render.d.ts +36 -0
  42. package/dist/cli/strategy-render.js +391 -0
  43. package/dist/cli/strategy-render.js.map +1 -0
  44. package/dist/cli/width.d.ts +18 -0
  45. package/dist/cli/width.js +71 -0
  46. package/dist/cli/width.js.map +1 -0
  47. package/dist/config/loader.js +1 -1
  48. package/dist/config/schema.d.ts +7 -0
  49. package/dist/config/schema.js +24 -0
  50. package/dist/config/schema.js.map +1 -1
  51. package/dist/core/okx-client.d.ts +9 -1
  52. package/dist/core/okx-client.js +14 -5
  53. package/dist/core/okx-client.js.map +1 -1
  54. package/dist/i18n/locale.d.ts +24 -0
  55. package/dist/i18n/locale.js +65 -0
  56. package/dist/i18n/locale.js.map +1 -0
  57. package/dist/i18n/messages.d.ts +333 -0
  58. package/dist/i18n/messages.js +660 -0
  59. package/dist/i18n/messages.js.map +1 -0
  60. package/dist/live/account-snapshot.d.ts +30 -0
  61. package/dist/live/account-snapshot.js +130 -0
  62. package/dist/live/account-snapshot.js.map +1 -0
  63. package/dist/live/cutoff-queue.d.ts +42 -0
  64. package/dist/live/cutoff-queue.js +69 -0
  65. package/dist/live/cutoff-queue.js.map +1 -0
  66. package/dist/live/execution-key.d.ts +23 -0
  67. package/dist/live/execution-key.js +31 -0
  68. package/dist/live/execution-key.js.map +1 -0
  69. package/dist/live/failures.d.ts +37 -0
  70. package/dist/live/failures.js +57 -0
  71. package/dist/live/failures.js.map +1 -0
  72. package/dist/live/gates.d.ts +65 -0
  73. package/dist/live/gates.js +136 -0
  74. package/dist/live/gates.js.map +1 -0
  75. package/dist/live/loop.d.ts +56 -0
  76. package/dist/live/loop.js +197 -0
  77. package/dist/live/loop.js.map +1 -0
  78. package/dist/live/preconditions.d.ts +48 -0
  79. package/dist/live/preconditions.js +69 -0
  80. package/dist/live/preconditions.js.map +1 -0
  81. package/dist/live/reconcile.d.ts +46 -0
  82. package/dist/live/reconcile.js +104 -0
  83. package/dist/live/reconcile.js.map +1 -0
  84. package/dist/live/runner.d.ts +57 -0
  85. package/dist/live/runner.js +160 -0
  86. package/dist/live/runner.js.map +1 -0
  87. package/dist/live/schema.d.ts +18 -0
  88. package/dist/live/schema.js +91 -0
  89. package/dist/live/schema.js.map +1 -0
  90. package/dist/live/service.d.ts +144 -0
  91. package/dist/live/service.js +303 -0
  92. package/dist/live/service.js.map +1 -0
  93. package/dist/live/session.d.ts +85 -0
  94. package/dist/live/session.js +234 -0
  95. package/dist/live/session.js.map +1 -0
  96. package/dist/live/sizing.d.ts +62 -0
  97. package/dist/live/sizing.js +79 -0
  98. package/dist/live/sizing.js.map +1 -0
  99. package/dist/live/store.d.ts +123 -0
  100. package/dist/live/store.js +350 -0
  101. package/dist/live/store.js.map +1 -0
  102. package/dist/live/types.d.ts +82 -0
  103. package/dist/live/types.js +2 -0
  104. package/dist/live/types.js.map +1 -0
  105. package/dist/market/websocket.d.ts +16 -1
  106. package/dist/market/websocket.js +60 -5
  107. package/dist/market/websocket.js.map +1 -1
  108. package/dist/mcp/server.d.ts +1 -0
  109. package/dist/mcp/server.js +15 -1
  110. package/dist/mcp/server.js.map +1 -1
  111. package/dist/network/connectivity.d.ts +9 -1
  112. package/dist/network/connectivity.js +28 -1
  113. package/dist/network/connectivity.js.map +1 -1
  114. package/dist/report/chart-script.d.ts +12 -0
  115. package/dist/report/chart-script.js +146 -0
  116. package/dist/report/chart-script.js.map +1 -0
  117. package/dist/report/compare-html.d.ts +8 -0
  118. package/dist/report/compare-html.js +254 -0
  119. package/dist/report/compare-html.js.map +1 -0
  120. package/dist/report/compare-script.d.ts +12 -0
  121. package/dist/report/compare-script.js +109 -0
  122. package/dist/report/compare-script.js.map +1 -0
  123. package/dist/report/compare.d.ts +61 -0
  124. package/dist/report/compare.js +205 -0
  125. package/dist/report/compare.js.map +1 -0
  126. package/dist/report/fetch.d.ts +20 -0
  127. package/dist/report/fetch.js +56 -0
  128. package/dist/report/fetch.js.map +1 -0
  129. package/dist/report/html.d.ts +54 -0
  130. package/dist/report/html.js +641 -0
  131. package/dist/report/html.js.map +1 -0
  132. package/dist/report/open.d.ts +42 -0
  133. package/dist/report/open.js +114 -0
  134. package/dist/report/open.js.map +1 -0
  135. package/dist/runtime/server.d.ts +16 -1
  136. package/dist/runtime/server.js +112 -9
  137. package/dist/runtime/server.js.map +1 -1
  138. package/dist/setup/installer.d.ts +1 -0
  139. package/dist/setup/installer.js +8 -0
  140. package/dist/setup/installer.js.map +1 -1
  141. package/dist/setup/wizard.d.ts +3 -0
  142. package/dist/setup/wizard.js +131 -6
  143. package/dist/setup/wizard.js.map +1 -1
  144. package/dist/strategy/constants.d.ts +23 -0
  145. package/dist/strategy/constants.js +24 -0
  146. package/dist/strategy/constants.js.map +1 -0
  147. package/dist/strategy/environment.d.ts +52 -0
  148. package/dist/strategy/environment.js +187 -0
  149. package/dist/strategy/environment.js.map +1 -0
  150. package/dist/strategy/instrument.d.ts +29 -0
  151. package/dist/strategy/instrument.js +39 -0
  152. package/dist/strategy/instrument.js.map +1 -0
  153. package/dist/strategy/optimize.d.ts +73 -0
  154. package/dist/strategy/optimize.js +113 -0
  155. package/dist/strategy/optimize.js.map +1 -0
  156. package/dist/strategy/parameter-space.d.ts +59 -0
  157. package/dist/strategy/parameter-space.js +221 -0
  158. package/dist/strategy/parameter-space.js.map +1 -0
  159. package/dist/strategy/python-bridge.d.ts +24 -0
  160. package/dist/strategy/python-bridge.js +114 -0
  161. package/dist/strategy/python-bridge.js.map +1 -0
  162. package/dist/strategy/schema.d.ts +9 -0
  163. package/dist/strategy/schema.js +91 -0
  164. package/dist/strategy/schema.js.map +1 -0
  165. package/dist/strategy/service.d.ts +138 -0
  166. package/dist/strategy/service.js +745 -0
  167. package/dist/strategy/service.js.map +1 -0
  168. package/dist/strategy/settings.d.ts +162 -0
  169. package/dist/strategy/settings.js +243 -0
  170. package/dist/strategy/settings.js.map +1 -0
  171. package/dist/strategy/store.d.ts +96 -0
  172. package/dist/strategy/store.js +367 -0
  173. package/dist/strategy/store.js.map +1 -0
  174. package/dist/strategy/templates.d.ts +11 -0
  175. package/dist/strategy/templates.js +134 -0
  176. package/dist/strategy/templates.js.map +1 -0
  177. package/dist/strategy/types.d.ts +111 -0
  178. package/dist/strategy/types.js +2 -0
  179. package/dist/strategy/types.js.map +1 -0
  180. package/dist/tools/catalog.d.ts +16 -0
  181. package/dist/tools/catalog.js +118 -17
  182. package/dist/tools/catalog.js.map +1 -1
  183. package/dist/trade/service.d.ts +12 -0
  184. package/dist/trade/service.js +24 -7
  185. package/dist/trade/service.js.map +1 -1
  186. package/dist/tui/app.d.ts +23 -0
  187. package/dist/tui/app.js +322 -0
  188. package/dist/tui/app.js.map +1 -0
  189. package/dist/tui/commands.d.ts +70 -0
  190. package/dist/tui/commands.js +313 -0
  191. package/dist/tui/commands.js.map +1 -0
  192. package/dist/tui/entries.d.ts +17 -0
  193. package/dist/tui/entries.js +24 -0
  194. package/dist/tui/entries.js.map +1 -0
  195. package/dist/tui/execute.d.ts +26 -0
  196. package/dist/tui/execute.js +664 -0
  197. package/dist/tui/execute.js.map +1 -0
  198. package/dist/tui/history.d.ts +17 -0
  199. package/dist/tui/history.js +48 -0
  200. package/dist/tui/history.js.map +1 -0
  201. package/dist/tui/index.d.ts +8 -0
  202. package/dist/tui/index.js +48 -0
  203. package/dist/tui/index.js.map +1 -0
  204. package/dist/tui/line-editor.d.ts +44 -0
  205. package/dist/tui/line-editor.js +98 -0
  206. package/dist/tui/line-editor.js.map +1 -0
  207. package/dist/tui/progress.d.ts +23 -0
  208. package/dist/tui/progress.js +46 -0
  209. package/dist/tui/progress.js.map +1 -0
  210. package/dist/tui/settings-editor.d.ts +18 -0
  211. package/dist/tui/settings-editor.js +115 -0
  212. package/dist/tui/settings-editor.js.map +1 -0
  213. package/docs/live-trading.md +455 -0
  214. package/docs/strategy-research.md +597 -0
  215. package/package.json +11 -1
  216. package/python/desic_strategy/__init__.py +34 -0
  217. package/python/desic_strategy/actions.py +158 -0
  218. package/python/desic_strategy/context.py +164 -0
  219. package/python/desic_strategy/engine.py +614 -0
  220. package/python/desic_strategy/indicators.py +159 -0
  221. package/python/desic_strategy/live.py +253 -0
  222. package/python/desic_strategy/policy.py +193 -0
  223. package/python/desic_strategy/portfolio.py +152 -0
  224. package/python/desic_strategy/report.py +319 -0
  225. package/python/desic_strategy/runner.py +574 -0
  226. package/python/desic_strategy/timeframe.py +150 -0
  227. package/python/main.py +18 -0
  228. package/skills/okx-live-trading/SKILL.md +117 -0
  229. package/skills/okx-live-trading/agents/openai.yaml +9 -0
  230. package/skills/okx-live-trading/references/lifecycle.md +128 -0
  231. package/skills/okx-strategy-research/SKILL.md +113 -0
  232. package/skills/okx-strategy-research/agents/openai.yaml +9 -0
  233. package/skills/okx-strategy-research/references/execution-semantics.md +107 -0
  234. package/skills/okx-strategy-research/references/field-traps.md +142 -0
  235. package/skills/okx-strategy-research/references/python-api.md +121 -0
  236. package/skills/okx-strategy-research/references/tools-and-data.md +192 -0
  237. package/skills/okx-trading/SKILL.md +11 -10
@@ -0,0 +1,574 @@
1
+ """Process entry point for one backtest or one source validation.
2
+
3
+ Contract with the host:
4
+
5
+ * stdin - one JSON request object
6
+ * stdout - one JSON response object
7
+ * stderr - ``PROGRESS {...}`` lines while running, then any diagnostics
8
+
9
+ The strategy module is executed with a restricted ``__builtins__`` mapping after
10
+ static policy validation. That is dependency and mistake isolation, not an OS
11
+ sandbox: the host still only runs source the user has chosen to trust.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import sys
18
+ from typing import Any
19
+
20
+ from .actions import StrategyError
21
+ from .engine import Costs, Engine, Instrument, RunConfig, Sizing
22
+ from .indicators import IndicatorError
23
+ from .live import LiveSession, parse_portfolio
24
+ from .policy import validate_source
25
+ from .report import build_report, calculate_metrics
26
+ from .timeframe import Bar, DataContractError, ONE_MINUTE_MS, interval_ms
27
+
28
+ PROTOCOL_VERSION = "desic.strategy/v1"
29
+
30
+ # Builtins a numeric strategy legitimately needs. Everything absent here — file
31
+ # and network access, dynamic import, eval, attribute probing — is unavailable.
32
+ _SAFE_BUILTINS = {
33
+ "abs": abs,
34
+ "all": all,
35
+ "any": any,
36
+ "bool": bool,
37
+ "dict": dict,
38
+ "divmod": divmod,
39
+ "enumerate": enumerate,
40
+ "filter": filter,
41
+ "float": float,
42
+ "format": format,
43
+ "frozenset": frozenset,
44
+ "int": int,
45
+ "isinstance": isinstance,
46
+ "issubclass": issubclass,
47
+ "iter": iter,
48
+ "len": len,
49
+ "list": list,
50
+ "map": map,
51
+ "max": max,
52
+ "min": min,
53
+ "next": next,
54
+ "pow": pow,
55
+ "print": print,
56
+ "range": range,
57
+ "repr": repr,
58
+ "reversed": reversed,
59
+ "round": round,
60
+ "set": set,
61
+ "slice": slice,
62
+ "sorted": sorted,
63
+ "str": str,
64
+ "sum": sum,
65
+ "tuple": tuple,
66
+ "zip": zip,
67
+ "True": True,
68
+ "False": False,
69
+ "None": None,
70
+ "ValueError": ValueError,
71
+ "TypeError": TypeError,
72
+ "KeyError": KeyError,
73
+ "IndexError": IndexError,
74
+ "ZeroDivisionError": ZeroDivisionError,
75
+ "ArithmeticError": ArithmeticError,
76
+ "Exception": Exception,
77
+ "__build_class__": __build_class__,
78
+ "__name__": "desic_strategy_module",
79
+ }
80
+
81
+
82
+ def main(argv: list[str] | None = None) -> int:
83
+ raw = _read_request_text()
84
+ try:
85
+ request = json.loads(raw) if raw.strip() else {}
86
+ except json.JSONDecodeError as error:
87
+ _emit({"ok": False, "error": {"code": "bad_request", "message": f"Invalid JSON request: {error}"}})
88
+ return 2
89
+
90
+ command = str(request.get("command") or "")
91
+ try:
92
+ if command == "validate":
93
+ _emit(_validate(request))
94
+ return 0
95
+ if command == "backtest":
96
+ _emit(_backtest(request))
97
+ return 0
98
+ if command == "optimize":
99
+ _emit(_optimize(request))
100
+ return 0
101
+ if command == "live":
102
+ # Stays alive and keeps reading stdin, unlike every other command.
103
+ return _live(request)
104
+ _emit(
105
+ {
106
+ "ok": False,
107
+ "error": {
108
+ "code": "bad_request",
109
+ "message": (
110
+ f"Unknown command '{command}'. Expected 'validate', 'backtest', or 'optimize'."
111
+ ),
112
+ },
113
+ }
114
+ )
115
+ return 2
116
+ except (StrategyError, DataContractError, IndicatorError) as error:
117
+ _emit({"ok": False, "error": {"code": "strategy_error", "message": str(error)}})
118
+ return 1
119
+ except Exception as error: # noqa: BLE001 - surface any failure as structured output
120
+ _emit({"ok": False, "error": {"code": "internal", "message": f"{type(error).__name__}: {error}"}})
121
+ return 1
122
+
123
+
124
+ def _read_request_text() -> str:
125
+ """Reads the opening request without consuming what follows it.
126
+
127
+ The one-shot commands send a single JSON object and close stdin, so reading to
128
+ EOF is correct for them. ``live`` keeps the pipe open and sends further
129
+ messages line by line, so the first line is read on its own — reading to EOF
130
+ there would swallow every bar and then block forever.
131
+ """
132
+ first = sys.stdin.readline()
133
+ if not first.strip():
134
+ return first
135
+ try:
136
+ parsed = json.loads(first)
137
+ except json.JSONDecodeError:
138
+ # Not a complete object on line one: a one-shot request pretty-printed
139
+ # across several lines. Read the rest and let the caller parse it.
140
+ return first + sys.stdin.read()
141
+ if isinstance(parsed, dict) and parsed.get("command") == "live":
142
+ return first
143
+ return first + sys.stdin.read()
144
+
145
+
146
+ def _validate(request: dict[str, Any]) -> dict[str, Any]:
147
+ source = str(request.get("source") or "")
148
+ violations = validate_source(source)
149
+ return {
150
+ "ok": True,
151
+ "protocol": PROTOCOL_VERSION,
152
+ "valid": not violations,
153
+ "violations": [item.as_dict() for item in violations],
154
+ }
155
+
156
+
157
+ def _backtest(request: dict[str, Any]) -> dict[str, Any]:
158
+ source = str(request.get("source") or "")
159
+ violations = validate_source(source)
160
+ if violations:
161
+ return {
162
+ "ok": False,
163
+ "error": {
164
+ "code": "policy_violation",
165
+ "message": "Strategy source failed the static policy check",
166
+ "violations": [item.as_dict() for item in violations],
167
+ },
168
+ }
169
+
170
+ bars = _parse_bars(request.get("bars"))
171
+ config = _parse_config(request)
172
+ params = dict(request.get("params") or {})
173
+ intervals = _parse_intervals(request.get("intervals"))
174
+ handlers = _load_handlers(source)
175
+
176
+ total = len(bars)
177
+ engine = Engine(config, handlers, params)
178
+ result = engine.run(bars, intervals, _progress)
179
+ report = build_report(result, config.initial_equity_usdt)
180
+ return {
181
+ "ok": True,
182
+ "protocol": PROTOCOL_VERSION,
183
+ "barCount": total,
184
+ "preloadBars": config.preload_bars,
185
+ "report": report,
186
+ }
187
+
188
+
189
+ def _live(request: dict[str, Any]) -> int:
190
+ """Runs one long-lived session, evaluating one bar per message.
191
+
192
+ Protocol, one JSON object per line in each direction:
193
+
194
+ → {"command":"live","source":...,"instrument":...,"params":...}
195
+ ← {"ok":true,"protocol":...,"handlers":[...]}
196
+ → {"kind":"warm","bars":[...]} history, no decisions
197
+ ← {"ok":true,"kind":"warm","barCount":N}
198
+ → {"kind":"bar","bar":{...},"portfolio":{...}}
199
+ ← {"ok":true,"kind":"action","action":"open_long",...}
200
+ → {"command":"shutdown"}
201
+
202
+ A failure on one bar is reported and the session stays up: a single bad
203
+ evaluation is this minute's problem, and tearing down the process would lose
204
+ the warmed indicator state for every minute after it. Only a protocol
205
+ violation or an explicit shutdown ends the loop.
206
+ """
207
+ source = str(request.get("source") or "")
208
+ violations = validate_source(source)
209
+ if violations:
210
+ _emit(
211
+ {
212
+ "ok": False,
213
+ "error": {
214
+ "code": "policy_violation",
215
+ "message": "Strategy source failed the static policy check",
216
+ "violations": [item.as_dict() for item in violations],
217
+ },
218
+ }
219
+ )
220
+ return 1
221
+
222
+ instrument_raw = request.get("instrument")
223
+ if not isinstance(instrument_raw, dict) or not str(instrument_raw.get("instId") or ""):
224
+ _emit({"ok": False, "error": {"code": "bad_request", "message": "instrument.instId is required"}})
225
+ return 2
226
+ instrument_id = str(instrument_raw["instId"])
227
+ intervals = _parse_intervals(request.get("intervals"))
228
+ handlers = _load_handlers(source)
229
+ session = LiveSession(instrument_id, handlers, dict(request.get("params") or {}), intervals)
230
+
231
+ _emit({"ok": True, "protocol": PROTOCOL_VERSION, "kind": "ready", "handlers": sorted(handlers)})
232
+
233
+ for line in sys.stdin:
234
+ if not line.strip():
235
+ continue
236
+ try:
237
+ message = json.loads(line)
238
+ except json.JSONDecodeError as error:
239
+ # A malformed frame means the stream is no longer trustworthy; a
240
+ # desynchronised pipe must not be interpreted as trading input.
241
+ _emit({"ok": False, "error": {"code": "bad_request", "message": f"Invalid JSON message: {error}"}})
242
+ return 2
243
+ if not isinstance(message, dict):
244
+ _emit({"ok": False, "error": {"code": "bad_request", "message": "Each message must be an object"}})
245
+ return 2
246
+
247
+ if message.get("command") == "shutdown":
248
+ _emit({"ok": True, "kind": "shutdown"})
249
+ return 0
250
+
251
+ kind = str(message.get("kind") or "")
252
+ try:
253
+ if kind == "warm":
254
+ count = session.warm(_parse_bars(message.get("bars")))
255
+ _emit({"ok": True, "kind": "warm", "barCount": count})
256
+ continue
257
+ if kind == "bar":
258
+ bars = _parse_bars([message.get("bar")])
259
+ portfolio = parse_portfolio(message.get("portfolio"), instrument_id)
260
+ decision = session.evaluate(bars[0], portfolio)
261
+ _emit(
262
+ {
263
+ "ok": True,
264
+ "kind": "action",
265
+ "asOfMs": bars[0].close_time_ms,
266
+ "barCount": session.bar_count,
267
+ **decision.as_dict(),
268
+ }
269
+ )
270
+ continue
271
+ _emit({"ok": False, "error": {"code": "bad_request", "message": f"Unknown message kind '{kind}'"}})
272
+ except (StrategyError, DataContractError, IndicatorError) as error:
273
+ _emit({"ok": False, "kind": "error", "error": {"code": "strategy_error", "message": str(error)}})
274
+ except Exception as error: # noqa: BLE001 - one bad bar must not end the session
275
+ _emit(
276
+ {
277
+ "ok": False,
278
+ "kind": "error",
279
+ "error": {"code": "internal", "message": f"{type(error).__name__}: {error}"},
280
+ }
281
+ )
282
+ # stdin closed without a shutdown message: the host is gone.
283
+ return 0
284
+
285
+
286
+ def _optimize(request: dict[str, Any]) -> dict[str, Any]:
287
+ """Evaluates many parameter sets over the same bars in one process.
288
+
289
+ The bars are parsed once and the candidates run in sequence. That is a memory
290
+ decision, not a speed one: transferring and parsing the payload is a small
291
+ share of a single candidate's cost, but spawning one process per candidate
292
+ would hold the same multi-megabyte array once per process.
293
+
294
+ Each candidate is measured twice — over the segment it was chosen on and over
295
+ a later segment it never saw. Both slices come from this one array, so the two
296
+ measurements cannot disagree about the underlying data.
297
+ """
298
+ source = str(request.get("source") or "")
299
+ violations = validate_source(source)
300
+ if violations:
301
+ return {
302
+ "ok": False,
303
+ "error": {
304
+ "code": "policy_violation",
305
+ "message": "Strategy source failed the static policy check",
306
+ "violations": [item.as_dict() for item in violations],
307
+ },
308
+ }
309
+
310
+ bars = _parse_bars(request.get("bars"))
311
+ base_config = _parse_config(request)
312
+ intervals = _parse_intervals(request.get("intervals"))
313
+ handlers = _load_handlers(source)
314
+ segments = _parse_segments(request.get("segments"), len(bars))
315
+ candidates = _parse_candidates(request.get("candidates"))
316
+
317
+ total_units = len(candidates) * len(segments)
318
+ done_units = 0
319
+ results: list[dict[str, Any]] = []
320
+
321
+ for candidate in candidates:
322
+ index = int(candidate["index"])
323
+ params = dict(candidate["params"])
324
+ measured: dict[str, Any] = {}
325
+ failure: str | None = None
326
+ for segment in segments:
327
+ slice_bars = bars[segment["fromIndex"] : segment["toIndex"] + 1]
328
+ config = _with_preload(base_config, segment["preloadBars"])
329
+ try:
330
+ engine = Engine(config, handlers, params)
331
+ result = engine.run(slice_bars, intervals, None)
332
+ measured[segment["name"]] = calculate_metrics(
333
+ result, config.initial_equity_usdt
334
+ ).as_dict()
335
+ except (StrategyError, DataContractError, IndicatorError) as error:
336
+ failure = f"{segment['name']}: {error}"
337
+ except Exception as error: # noqa: BLE001 - one candidate must not end the batch
338
+ failure = f"{segment['name']}: {type(error).__name__}: {error}"
339
+ done_units += 1
340
+ _progress(done_units, total_units)
341
+ if failure is not None:
342
+ break
343
+ if failure is None:
344
+ results.append({"index": index, "ok": True, "metrics": measured})
345
+ else:
346
+ # Recorded rather than raised: one unusable combination is a data
347
+ # point about that combination, not a reason to discard the others.
348
+ results.append({"index": index, "ok": False, "error": failure})
349
+
350
+ _progress(total_units, total_units)
351
+ return {
352
+ "ok": True,
353
+ "protocol": PROTOCOL_VERSION,
354
+ "barCount": len(bars),
355
+ "candidateCount": len(candidates),
356
+ "results": results,
357
+ }
358
+
359
+
360
+ def _progress(done: int, total: int) -> None:
361
+ payload = {"done": done, "total": total, "pct": 0.0 if total == 0 else done / total * 100}
362
+ sys.stderr.write(f"PROGRESS {json.dumps(payload)}\n")
363
+ sys.stderr.flush()
364
+
365
+
366
+ def _with_preload(config: RunConfig, preload_bars: int) -> RunConfig:
367
+ """Copies a config with a different preload length.
368
+
369
+ Each segment carries its own warm-up prefix, so the count differs per segment
370
+ while every other assumption stays identical between them.
371
+ """
372
+ return RunConfig(
373
+ instrument=config.instrument,
374
+ initial_equity_usdt=config.initial_equity_usdt,
375
+ costs=config.costs,
376
+ sizing=config.sizing,
377
+ preload_bars=preload_bars,
378
+ close_at_end=config.close_at_end,
379
+ limit_order_ttl_bars=config.limit_order_ttl_bars,
380
+ )
381
+
382
+
383
+ def _parse_segments(value: Any, bar_count: int) -> list[dict[str, Any]]:
384
+ if not isinstance(value, list) or not value:
385
+ raise DataContractError("'segments' must be a non-empty array")
386
+ segments: list[dict[str, Any]] = []
387
+ for position, row in enumerate(value):
388
+ if not isinstance(row, dict):
389
+ raise DataContractError(f"segments[{position}] must be an object")
390
+ try:
391
+ name = str(row["name"])
392
+ from_index = int(row["fromIndex"])
393
+ to_index = int(row["toIndex"])
394
+ preload = int(row["preloadBars"])
395
+ except (KeyError, TypeError, ValueError) as error:
396
+ raise DataContractError(f"segments[{position}] is malformed: {error}") from error
397
+ if not 0 <= from_index <= to_index < bar_count:
398
+ raise DataContractError(
399
+ f"segments[{position}] range {from_index}..{to_index} falls outside the {bar_count} supplied bars"
400
+ )
401
+ if preload < 2:
402
+ raise DataContractError(f"segments[{position}].preloadBars must be at least 2")
403
+ # An evaluation bar has to remain after the warm-up prefix, or the segment
404
+ # measures nothing while still reporting a completed run.
405
+ if to_index - from_index + 1 <= preload:
406
+ raise DataContractError(
407
+ f"segments[{position}] holds {to_index - from_index + 1} bars, which is not more than its {preload} preloaded bars"
408
+ )
409
+ segments.append(
410
+ {"name": name, "fromIndex": from_index, "toIndex": to_index, "preloadBars": preload}
411
+ )
412
+ return segments
413
+
414
+
415
+ def _parse_candidates(value: Any) -> list[dict[str, Any]]:
416
+ if not isinstance(value, list) or not value:
417
+ raise DataContractError("'candidates' must be a non-empty array")
418
+ candidates: list[dict[str, Any]] = []
419
+ seen: set[int] = set()
420
+ for position, row in enumerate(value):
421
+ if not isinstance(row, dict):
422
+ raise DataContractError(f"candidates[{position}] must be an object")
423
+ params = row.get("params")
424
+ if not isinstance(params, dict):
425
+ raise DataContractError(f"candidates[{position}].params must be an object")
426
+ try:
427
+ index = int(row["index"])
428
+ except (KeyError, TypeError, ValueError) as error:
429
+ raise DataContractError(f"candidates[{position}].index is malformed: {error}") from error
430
+ # Indices key the host's own rows, so a duplicate would overwrite one
431
+ # candidate's result with another's.
432
+ if index in seen:
433
+ raise DataContractError(f"candidates[{position}] repeats index {index}")
434
+ seen.add(index)
435
+ candidates.append({"index": index, "params": params})
436
+ return candidates
437
+
438
+
439
+ def _parse_bars(value: Any) -> list[Bar]:
440
+ if not isinstance(value, list) or not value:
441
+ raise DataContractError("A backtest requires a non-empty 'bars' array")
442
+ bars: list[Bar] = []
443
+ previous_open: int | None = None
444
+ for index, row in enumerate(value):
445
+ if not isinstance(row, dict):
446
+ raise DataContractError(f"bars[{index}] must be an object")
447
+ try:
448
+ open_time = int(row["openTimeMs"])
449
+ close_time = int(row["closeTimeMs"])
450
+ bar = Bar(
451
+ open_time_ms=open_time,
452
+ close_time_ms=close_time,
453
+ open=float(row["open"]),
454
+ high=float(row["high"]),
455
+ low=float(row["low"]),
456
+ close=float(row["close"]),
457
+ volume=float(row["volume"]),
458
+ confirmed=True,
459
+ )
460
+ except (KeyError, TypeError, ValueError) as error:
461
+ raise DataContractError(f"bars[{index}] is malformed: {error}") from error
462
+ if close_time - open_time != ONE_MINUTE_MS:
463
+ raise DataContractError(f"bars[{index}] is not exactly one minute long")
464
+ if previous_open is not None and open_time != previous_open + ONE_MINUTE_MS:
465
+ raise DataContractError(
466
+ f"bars[{index}] breaks continuity: expected open {previous_open + ONE_MINUTE_MS}, got {open_time}"
467
+ )
468
+ previous_open = open_time
469
+ bars.append(bar)
470
+ return bars
471
+
472
+
473
+ def _parse_intervals(value: Any) -> list[str]:
474
+ if value is None:
475
+ return ["1m"]
476
+ if not isinstance(value, list):
477
+ raise DataContractError("'intervals' must be an array of interval names")
478
+ intervals = ["1m"]
479
+ for item in value:
480
+ name = str(item)
481
+ interval_ms(name)
482
+ if name not in intervals:
483
+ intervals.append(name)
484
+ return intervals
485
+
486
+
487
+ def _parse_config(request: dict[str, Any]) -> RunConfig:
488
+ instrument_raw = request.get("instrument")
489
+ if not isinstance(instrument_raw, dict):
490
+ raise DataContractError("'instrument' is required")
491
+ instrument = Instrument(
492
+ inst_id=str(instrument_raw.get("instId") or ""),
493
+ contract_value=float(instrument_raw.get("contractValue", 1)),
494
+ lot_size=float(instrument_raw.get("lotSize", 1)),
495
+ min_size=float(instrument_raw.get("minSize", 1)),
496
+ tick_size=float(instrument_raw.get("tickSize", 0.1)),
497
+ contract_multiplier=float(instrument_raw.get("contractMultiplier", 1)),
498
+ )
499
+ if not instrument.inst_id:
500
+ raise DataContractError("instrument.instId is required")
501
+ # Every specification is required to be positive. A zero or negative value
502
+ # would silently produce nonsense position sizes rather than failing, and a
503
+ # backtest reporting confident numbers off a bad contract spec is worse than
504
+ # one that refuses to run.
505
+ for field, value in (
506
+ ("contractValue", instrument.contract_value),
507
+ ("contractMultiplier", instrument.contract_multiplier),
508
+ ("lotSize", instrument.lot_size),
509
+ ("minSize", instrument.min_size),
510
+ ):
511
+ if not value > 0:
512
+ raise DataContractError(f"instrument.{field} must be greater than zero")
513
+
514
+ costs_raw = request.get("costs") or {}
515
+ sizing_raw = request.get("sizing") or {}
516
+ leverage = float(sizing_raw.get("leverage", 10))
517
+ if not 1 <= leverage <= 125:
518
+ raise DataContractError("sizing.leverage must be between 1 and 125")
519
+ safety = float(sizing_raw.get("marginSafetyMultiplier", 1))
520
+ if not 1 <= safety <= 20:
521
+ raise DataContractError("sizing.marginSafetyMultiplier must be between 1 and 20")
522
+
523
+ preload = int(request.get("preloadBars", 2))
524
+ if preload < 2:
525
+ raise DataContractError("preloadBars must be at least 2 so an action has a following open to fill at")
526
+ initial_equity = float(request.get("initialEquityUsdt", 10_000))
527
+ if initial_equity <= 0:
528
+ raise DataContractError("initialEquityUsdt must be greater than zero")
529
+
530
+ return RunConfig(
531
+ instrument=instrument,
532
+ initial_equity_usdt=initial_equity,
533
+ costs=Costs(
534
+ taker_fee_rate=float(costs_raw.get("takerFeeRate", 0.0005)),
535
+ maker_fee_rate=float(costs_raw.get("makerFeeRate", 0.0002)),
536
+ entry_slippage_bps=float(costs_raw.get("entrySlippageBps", 1.0)),
537
+ exit_slippage_bps=float(costs_raw.get("exitSlippageBps", 1.0)),
538
+ ),
539
+ sizing=Sizing(
540
+ leverage=leverage,
541
+ margin_safety_multiplier=safety,
542
+ entry_budget_usdt=(
543
+ None if sizing_raw.get("entryBudgetUsdt") is None else float(sizing_raw["entryBudgetUsdt"])
544
+ ),
545
+ entry_budget_pct=float(sizing_raw.get("entryBudgetPct", 0.20)),
546
+ ),
547
+ preload_bars=preload,
548
+ close_at_end=bool(request.get("closeAtEnd", True)),
549
+ limit_order_ttl_bars=int(request.get("limitOrderTtlBars", 60)),
550
+ )
551
+
552
+
553
+ def _load_handlers(source: str) -> dict[str, object]:
554
+ namespace: dict[str, Any] = {"__builtins__": dict(_SAFE_BUILTINS)}
555
+ compiled = compile(source, "<strategy>", "exec")
556
+ exec(compiled, namespace) # noqa: S102 - policy-validated strategy source
557
+ handlers: dict[str, object] = {}
558
+ for name in ("on_bar", "on_start"):
559
+ candidate = namespace.get(name)
560
+ if callable(candidate):
561
+ handlers[name] = candidate
562
+ if "on_bar" not in handlers:
563
+ raise StrategyError("A strategy must define on_bar(ctx)")
564
+ return handlers
565
+
566
+
567
+ def _emit(payload: dict[str, Any]) -> None:
568
+ sys.stdout.write(json.dumps(payload, separators=(",", ":"), sort_keys=True))
569
+ sys.stdout.write("\n")
570
+ sys.stdout.flush()
571
+
572
+
573
+ if __name__ == "__main__":
574
+ raise SystemExit(main())