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.
- package/README.en.md +353 -0
- package/README.md +194 -190
- package/dist/account/private-websocket.js +4 -4
- package/dist/account/private-websocket.js.map +1 -1
- package/dist/account/service.d.ts +12 -1
- package/dist/account/service.js +18 -0
- package/dist/account/service.js.map +1 -1
- package/dist/bars/rate-limiter.d.ts +18 -0
- package/dist/bars/rate-limiter.js +84 -0
- package/dist/bars/rate-limiter.js.map +1 -0
- package/dist/bars/schema.d.ts +36 -0
- package/dist/bars/schema.js +134 -0
- package/dist/bars/schema.js.map +1 -0
- package/dist/bars/service.d.ts +60 -0
- package/dist/bars/service.js +120 -0
- package/dist/bars/service.js.map +1 -0
- package/dist/bars/store.d.ts +105 -0
- package/dist/bars/store.js +415 -0
- package/dist/bars/store.js.map +1 -0
- package/dist/bars/timeframe.d.ts +40 -0
- package/dist/bars/timeframe.js +146 -0
- package/dist/bars/timeframe.js.map +1 -0
- package/dist/bars/types.d.ts +68 -0
- package/dist/bars/types.js +13 -0
- package/dist/bars/types.js.map +1 -0
- package/dist/cli/data-render.d.ts +37 -0
- package/dist/cli/data-render.js +143 -0
- package/dist/cli/data-render.js.map +1 -0
- package/dist/cli/doctor.js +7 -3
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.js +758 -27
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/live-render.d.ts +24 -0
- package/dist/cli/live-render.js +85 -0
- package/dist/cli/live-render.js.map +1 -0
- package/dist/cli/range.d.ts +28 -0
- package/dist/cli/range.js +63 -0
- package/dist/cli/range.js.map +1 -0
- package/dist/cli/render.js +3 -0
- package/dist/cli/render.js.map +1 -1
- package/dist/cli/strategy-render.d.ts +36 -0
- package/dist/cli/strategy-render.js +391 -0
- package/dist/cli/strategy-render.js.map +1 -0
- package/dist/cli/width.d.ts +18 -0
- package/dist/cli/width.js +71 -0
- package/dist/cli/width.js.map +1 -0
- package/dist/config/loader.js +1 -1
- package/dist/config/schema.d.ts +7 -0
- package/dist/config/schema.js +24 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/core/okx-client.d.ts +9 -1
- package/dist/core/okx-client.js +14 -5
- package/dist/core/okx-client.js.map +1 -1
- package/dist/i18n/locale.d.ts +24 -0
- package/dist/i18n/locale.js +65 -0
- package/dist/i18n/locale.js.map +1 -0
- package/dist/i18n/messages.d.ts +333 -0
- package/dist/i18n/messages.js +660 -0
- package/dist/i18n/messages.js.map +1 -0
- package/dist/live/account-snapshot.d.ts +30 -0
- package/dist/live/account-snapshot.js +130 -0
- package/dist/live/account-snapshot.js.map +1 -0
- package/dist/live/cutoff-queue.d.ts +42 -0
- package/dist/live/cutoff-queue.js +69 -0
- package/dist/live/cutoff-queue.js.map +1 -0
- package/dist/live/execution-key.d.ts +23 -0
- package/dist/live/execution-key.js +31 -0
- package/dist/live/execution-key.js.map +1 -0
- package/dist/live/failures.d.ts +37 -0
- package/dist/live/failures.js +57 -0
- package/dist/live/failures.js.map +1 -0
- package/dist/live/gates.d.ts +65 -0
- package/dist/live/gates.js +136 -0
- package/dist/live/gates.js.map +1 -0
- package/dist/live/loop.d.ts +56 -0
- package/dist/live/loop.js +197 -0
- package/dist/live/loop.js.map +1 -0
- package/dist/live/preconditions.d.ts +48 -0
- package/dist/live/preconditions.js +69 -0
- package/dist/live/preconditions.js.map +1 -0
- package/dist/live/reconcile.d.ts +46 -0
- package/dist/live/reconcile.js +104 -0
- package/dist/live/reconcile.js.map +1 -0
- package/dist/live/runner.d.ts +57 -0
- package/dist/live/runner.js +160 -0
- package/dist/live/runner.js.map +1 -0
- package/dist/live/schema.d.ts +18 -0
- package/dist/live/schema.js +91 -0
- package/dist/live/schema.js.map +1 -0
- package/dist/live/service.d.ts +144 -0
- package/dist/live/service.js +303 -0
- package/dist/live/service.js.map +1 -0
- package/dist/live/session.d.ts +85 -0
- package/dist/live/session.js +234 -0
- package/dist/live/session.js.map +1 -0
- package/dist/live/sizing.d.ts +62 -0
- package/dist/live/sizing.js +79 -0
- package/dist/live/sizing.js.map +1 -0
- package/dist/live/store.d.ts +123 -0
- package/dist/live/store.js +350 -0
- package/dist/live/store.js.map +1 -0
- package/dist/live/types.d.ts +82 -0
- package/dist/live/types.js +2 -0
- package/dist/live/types.js.map +1 -0
- package/dist/market/websocket.d.ts +16 -1
- package/dist/market/websocket.js +60 -5
- package/dist/market/websocket.js.map +1 -1
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/server.js +15 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/network/connectivity.d.ts +9 -1
- package/dist/network/connectivity.js +28 -1
- package/dist/network/connectivity.js.map +1 -1
- package/dist/report/chart-script.d.ts +12 -0
- package/dist/report/chart-script.js +146 -0
- package/dist/report/chart-script.js.map +1 -0
- package/dist/report/compare-html.d.ts +8 -0
- package/dist/report/compare-html.js +254 -0
- package/dist/report/compare-html.js.map +1 -0
- package/dist/report/compare-script.d.ts +12 -0
- package/dist/report/compare-script.js +109 -0
- package/dist/report/compare-script.js.map +1 -0
- package/dist/report/compare.d.ts +61 -0
- package/dist/report/compare.js +205 -0
- package/dist/report/compare.js.map +1 -0
- package/dist/report/fetch.d.ts +20 -0
- package/dist/report/fetch.js +56 -0
- package/dist/report/fetch.js.map +1 -0
- package/dist/report/html.d.ts +54 -0
- package/dist/report/html.js +641 -0
- package/dist/report/html.js.map +1 -0
- package/dist/report/open.d.ts +42 -0
- package/dist/report/open.js +114 -0
- package/dist/report/open.js.map +1 -0
- package/dist/runtime/server.d.ts +16 -1
- package/dist/runtime/server.js +112 -9
- package/dist/runtime/server.js.map +1 -1
- package/dist/setup/installer.d.ts +1 -0
- package/dist/setup/installer.js +8 -0
- package/dist/setup/installer.js.map +1 -1
- package/dist/setup/wizard.d.ts +3 -0
- package/dist/setup/wizard.js +131 -6
- package/dist/setup/wizard.js.map +1 -1
- package/dist/strategy/constants.d.ts +23 -0
- package/dist/strategy/constants.js +24 -0
- package/dist/strategy/constants.js.map +1 -0
- package/dist/strategy/environment.d.ts +52 -0
- package/dist/strategy/environment.js +187 -0
- package/dist/strategy/environment.js.map +1 -0
- package/dist/strategy/instrument.d.ts +29 -0
- package/dist/strategy/instrument.js +39 -0
- package/dist/strategy/instrument.js.map +1 -0
- package/dist/strategy/optimize.d.ts +73 -0
- package/dist/strategy/optimize.js +113 -0
- package/dist/strategy/optimize.js.map +1 -0
- package/dist/strategy/parameter-space.d.ts +59 -0
- package/dist/strategy/parameter-space.js +221 -0
- package/dist/strategy/parameter-space.js.map +1 -0
- package/dist/strategy/python-bridge.d.ts +24 -0
- package/dist/strategy/python-bridge.js +114 -0
- package/dist/strategy/python-bridge.js.map +1 -0
- package/dist/strategy/schema.d.ts +9 -0
- package/dist/strategy/schema.js +91 -0
- package/dist/strategy/schema.js.map +1 -0
- package/dist/strategy/service.d.ts +138 -0
- package/dist/strategy/service.js +745 -0
- package/dist/strategy/service.js.map +1 -0
- package/dist/strategy/settings.d.ts +162 -0
- package/dist/strategy/settings.js +243 -0
- package/dist/strategy/settings.js.map +1 -0
- package/dist/strategy/store.d.ts +96 -0
- package/dist/strategy/store.js +367 -0
- package/dist/strategy/store.js.map +1 -0
- package/dist/strategy/templates.d.ts +11 -0
- package/dist/strategy/templates.js +134 -0
- package/dist/strategy/templates.js.map +1 -0
- package/dist/strategy/types.d.ts +111 -0
- package/dist/strategy/types.js +2 -0
- package/dist/strategy/types.js.map +1 -0
- package/dist/tools/catalog.d.ts +16 -0
- package/dist/tools/catalog.js +118 -17
- package/dist/tools/catalog.js.map +1 -1
- package/dist/trade/service.d.ts +12 -0
- package/dist/trade/service.js +24 -7
- package/dist/trade/service.js.map +1 -1
- package/dist/tui/app.d.ts +23 -0
- package/dist/tui/app.js +322 -0
- package/dist/tui/app.js.map +1 -0
- package/dist/tui/commands.d.ts +70 -0
- package/dist/tui/commands.js +313 -0
- package/dist/tui/commands.js.map +1 -0
- package/dist/tui/entries.d.ts +17 -0
- package/dist/tui/entries.js +24 -0
- package/dist/tui/entries.js.map +1 -0
- package/dist/tui/execute.d.ts +26 -0
- package/dist/tui/execute.js +664 -0
- package/dist/tui/execute.js.map +1 -0
- package/dist/tui/history.d.ts +17 -0
- package/dist/tui/history.js +48 -0
- package/dist/tui/history.js.map +1 -0
- package/dist/tui/index.d.ts +8 -0
- package/dist/tui/index.js +48 -0
- package/dist/tui/index.js.map +1 -0
- package/dist/tui/line-editor.d.ts +44 -0
- package/dist/tui/line-editor.js +98 -0
- package/dist/tui/line-editor.js.map +1 -0
- package/dist/tui/progress.d.ts +23 -0
- package/dist/tui/progress.js +46 -0
- package/dist/tui/progress.js.map +1 -0
- package/dist/tui/settings-editor.d.ts +18 -0
- package/dist/tui/settings-editor.js +115 -0
- package/dist/tui/settings-editor.js.map +1 -0
- package/docs/live-trading.md +455 -0
- package/docs/strategy-research.md +597 -0
- package/package.json +11 -1
- package/python/desic_strategy/__init__.py +34 -0
- package/python/desic_strategy/actions.py +158 -0
- package/python/desic_strategy/context.py +164 -0
- package/python/desic_strategy/engine.py +614 -0
- package/python/desic_strategy/indicators.py +159 -0
- package/python/desic_strategy/live.py +253 -0
- package/python/desic_strategy/policy.py +193 -0
- package/python/desic_strategy/portfolio.py +152 -0
- package/python/desic_strategy/report.py +319 -0
- package/python/desic_strategy/runner.py +574 -0
- package/python/desic_strategy/timeframe.py +150 -0
- package/python/main.py +18 -0
- package/skills/okx-live-trading/SKILL.md +117 -0
- package/skills/okx-live-trading/agents/openai.yaml +9 -0
- package/skills/okx-live-trading/references/lifecycle.md +128 -0
- package/skills/okx-strategy-research/SKILL.md +113 -0
- package/skills/okx-strategy-research/agents/openai.yaml +9 -0
- package/skills/okx-strategy-research/references/execution-semantics.md +107 -0
- package/skills/okx-strategy-research/references/field-traps.md +142 -0
- package/skills/okx-strategy-research/references/python-api.md +121 -0
- package/skills/okx-strategy-research/references/tools-and-data.md +192 -0
- 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())
|