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,159 @@
1
+ """Rolling indicators over the confirmed one-minute series.
2
+
3
+ Only ``1m`` is accepted. A higher-timeframe active bucket can still be revised
4
+ before it confirms, which makes it unsuitable for append-only indicator state:
5
+ the cached value would silently depend on when it was first computed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .timeframe import Bar
11
+
12
+ _LOOKUP_LIMIT = 5_000
13
+
14
+
15
+ class IndicatorError(Exception):
16
+ """Raised when an indicator is requested outside its contract."""
17
+
18
+
19
+ class IndicatorCache:
20
+ """Incrementally maintained EMA and Wilder ATR series.
21
+
22
+ Values are appended once per confirmed bar and never recomputed, so a
23
+ context retained from an earlier bar reads its own historical value rather
24
+ than a later one.
25
+ """
26
+
27
+ __slots__ = ("_atr", "_bars", "_ema")
28
+
29
+ def __init__(self) -> None:
30
+ self._bars: list[Bar] = []
31
+ self._ema: dict[int, list[float | None]] = {}
32
+ self._atr: dict[int, list[float | None]] = {}
33
+
34
+ def append(self, bar: Bar) -> None:
35
+ self._bars.append(bar)
36
+ for period, series in self._ema.items():
37
+ series.append(self._next_ema(period, series))
38
+ for period, series in self._atr.items():
39
+ series.append(self._next_atr(period, series))
40
+
41
+ @property
42
+ def length(self) -> int:
43
+ return len(self._bars)
44
+
45
+ def ema(self, period: int, offset: int, visible: int) -> float | None:
46
+ series = self._ensure(self._ema, period, self._next_ema)
47
+ return self._read(series, offset, visible)
48
+
49
+ def atr(self, period: int, offset: int, visible: int) -> float | None:
50
+ series = self._ensure(self._atr, period, self._next_atr)
51
+ return self._read(series, offset, visible)
52
+
53
+ def _ensure(
54
+ self,
55
+ store: dict[int, list[float | None]],
56
+ period: int,
57
+ step: object,
58
+ ) -> list[float | None]:
59
+ if not isinstance(period, int) or isinstance(period, bool) or period <= 0:
60
+ raise IndicatorError("period must be a positive integer")
61
+ if period > _LOOKUP_LIMIT:
62
+ raise IndicatorError(f"period must be at most {_LOOKUP_LIMIT}")
63
+ series = store.get(period)
64
+ if series is None:
65
+ # Backfill so a first request mid-run yields the same values it
66
+ # would have had if it were requested from the first bar.
67
+ series = []
68
+ store[period] = series
69
+ compute = step # type: ignore[assignment]
70
+ for index in range(len(self._bars)):
71
+ series.append(compute(period, series, index + 1)) # type: ignore[operator]
72
+ return series
73
+
74
+ def _read(self, series: list[float | None], offset: int, visible: int) -> float | None:
75
+ if not isinstance(offset, int) or isinstance(offset, bool) or offset < 0:
76
+ raise IndicatorError("offset must be a non-negative integer")
77
+ index = visible - 1 - offset
78
+ if index < 0 or index >= len(series):
79
+ return None
80
+ return series[index]
81
+
82
+ def _next_ema(
83
+ self,
84
+ period: int,
85
+ series: list[float | None],
86
+ upto: int | None = None,
87
+ ) -> float | None:
88
+ count = len(self._bars) if upto is None else upto
89
+ if count < period:
90
+ return None
91
+ if count == period:
92
+ # Seed with the simple mean of the first `period` closes.
93
+ return sum(self._bars[index].close for index in range(period)) / period
94
+ previous = series[count - 2] if count - 2 < len(series) else None
95
+ if previous is None:
96
+ return sum(self._bars[index].close for index in range(count - period, count)) / period
97
+ # Only the newest close is needed once seeded, keeping each bar O(1).
98
+ multiplier = 2.0 / (period + 1)
99
+ return (self._bars[count - 1].close - previous) * multiplier + previous
100
+
101
+ def _next_atr(
102
+ self,
103
+ period: int,
104
+ series: list[float | None],
105
+ upto: int | None = None,
106
+ ) -> float | None:
107
+ count = len(self._bars) if upto is None else upto
108
+ # True range needs a prior close, so ATR starts one bar later than EMA.
109
+ if count < period + 1:
110
+ return None
111
+ if count == period + 1:
112
+ # Seed with the mean of the first `period` true ranges.
113
+ return sum(self._true_range(index) for index in range(1, count)) / period
114
+ previous = series[count - 2] if count - 2 < len(series) else None
115
+ if previous is None:
116
+ return sum(self._true_range(index) for index in range(1, period + 1)) / period
117
+ # Wilder smoothing needs only the newest true range, so each bar stays
118
+ # O(1); recomputing the whole range list here would make a long run
119
+ # quadratic in bar count.
120
+ return (previous * (period - 1) + self._true_range(count - 1)) / period
121
+
122
+ def _true_range(self, index: int) -> float:
123
+ bar = self._bars[index]
124
+ previous_close = self._bars[index - 1].close
125
+ return max(
126
+ bar.high - bar.low,
127
+ abs(bar.high - previous_close),
128
+ abs(bar.low - previous_close),
129
+ )
130
+
131
+
132
+ class IndicatorView:
133
+ """Strategy-facing indicator accessor bound to one cutoff."""
134
+
135
+ __slots__ = ("_cache", "_instrument_id", "_visible")
136
+
137
+ def __init__(self, cache: IndicatorCache, instrument_id: str, visible: int) -> None:
138
+ self._cache = cache
139
+ self._instrument_id = instrument_id
140
+ self._visible = visible
141
+
142
+ def ema(self, instrument_id: str, interval: str, period: int, offset: int = 0) -> float | None:
143
+ self._check(instrument_id, interval)
144
+ return self._cache.ema(period, offset, self._visible)
145
+
146
+ def atr(self, instrument_id: str, interval: str, period: int, offset: int = 0) -> float | None:
147
+ self._check(instrument_id, interval)
148
+ return self._cache.atr(period, offset, self._visible)
149
+
150
+ def _check(self, instrument_id: str, interval: str) -> None:
151
+ if instrument_id != self._instrument_id:
152
+ raise IndicatorError(
153
+ f"Indicators are available for {self._instrument_id}, not {instrument_id}"
154
+ )
155
+ if interval != "1m":
156
+ raise IndicatorError(
157
+ "Indicators accept only the confirmed '1m' series; a higher-timeframe "
158
+ "active bucket can still be revised before it confirms"
159
+ )
@@ -0,0 +1,253 @@
1
+ """One long-lived strategy session, driven one bar at a time.
2
+
3
+ The backtest ``Engine`` owns a virtual account: it matches orders, charges fees,
4
+ and tracks a position it computed itself. None of that applies here. A live fill
5
+ is decided by the exchange, so the position handed to strategy code is the one
6
+ the exchange reports — read fresh before every evaluation and passed in by the
7
+ host.
8
+
9
+ That is why this is a separate driver rather than a mode of ``Engine``. Reusing
10
+ the virtual ledger would leave two sources of truth for the same position, and the
11
+ moment a real fill differed from the simulated one — partial fill, manual close,
12
+ liquidation — the strategy would be deciding against a position that does not
13
+ exist.
14
+
15
+ Everything a strategy actually touches *is* shared with the backtest:
16
+ ``SeriesSet``, ``IndicatorCache``, ``MarketView``, ``StrategyContext``, the action
17
+ constructors, and the source policy. Only the account state differs, because only
18
+ the account state can differ.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from .actions import Decision, StrategyError
24
+ from .context import MarketView, SeriesSet, StrategyContext, immutable_params
25
+ from .indicators import IndicatorView
26
+ from .portfolio import LONG, SHORT, OpenOrder, PortfolioView, Position
27
+ from .timeframe import Bar, DataContractError
28
+
29
+ # Bars retained in memory. A session runs for days, so the window is bounded; this
30
+ # is far more than any indicator lookback needs and keeps memory flat.
31
+ MAX_RETAINED_BARS = 20_000
32
+
33
+
34
+ class LiveSession:
35
+ """Evaluates one strategy against one instrument, one confirmed bar at a time."""
36
+
37
+ __slots__ = ("_bars", "_handlers", "_instrument_id", "_intervals", "_last_cutoff", "_params", "_series", "_started")
38
+
39
+ def __init__(
40
+ self,
41
+ instrument_id: str,
42
+ handlers: dict[str, object],
43
+ params: dict[str, object],
44
+ intervals: list[str],
45
+ ) -> None:
46
+ self._instrument_id = instrument_id
47
+ self._handlers = handlers
48
+ self._params = immutable_params(params)
49
+ self._intervals = intervals
50
+ self._series = SeriesSet(instrument_id, intervals)
51
+ self._bars: list[Bar] = []
52
+ self._started = False
53
+ self._last_cutoff: int | None = None
54
+
55
+ @property
56
+ def bar_count(self) -> int:
57
+ return len(self._bars)
58
+
59
+ @property
60
+ def last_cutoff_ms(self) -> int | None:
61
+ return self._last_cutoff
62
+
63
+ def warm(self, bars: list[Bar]) -> int:
64
+ """Feeds history without evaluating anything.
65
+
66
+ Indicators need a warm-up prefix, and running the strategy over it would
67
+ emit decisions for minutes that have already passed. Same rule as the
68
+ backtest's preload: warm-up produces no decisions.
69
+ """
70
+ for bar in bars:
71
+ self._append(bar)
72
+ return len(self._bars)
73
+
74
+ def evaluate(self, bar: Bar, portfolio: PortfolioView) -> Decision:
75
+ """Appends one confirmed bar and returns the single decision for it.
76
+
77
+ ``portfolio`` is the exchange's own view, read by the host immediately
78
+ before this call.
79
+ """
80
+ self._append(bar)
81
+ context = self._context(bar, portfolio)
82
+
83
+ if not self._started:
84
+ self._started = True
85
+ start_handler = self._handlers.get("on_start")
86
+ if callable(start_handler):
87
+ decision = start_handler(context)
88
+ if not isinstance(decision, Decision) or decision.action != "no_action":
89
+ raise StrategyError("on_start must return ctx.no_action(...)")
90
+
91
+ handler = self._handlers.get("on_bar")
92
+ if not callable(handler):
93
+ raise StrategyError("A strategy must define on_bar(ctx)")
94
+ decision = handler(context)
95
+ if not isinstance(decision, Decision):
96
+ raise StrategyError(
97
+ "A handler must return one of ctx.no_action, ctx.open_long, ctx.open_short, "
98
+ "ctx.close_long, ctx.close_short, or ctx.cancel_order"
99
+ )
100
+ self._last_cutoff = bar.close_time_ms
101
+ return decision
102
+
103
+ def _append(self, bar: Bar) -> None:
104
+ if not bar.confirmed:
105
+ # An unconfirmed bar can still be revised. Acting on one would mean
106
+ # deciding against a candle that has not finished happening.
107
+ raise DataContractError("A live session accepts only confirmed bars")
108
+ previous = self._bars[-1] if self._bars else None
109
+ if previous is not None:
110
+ if bar.open_time_ms == previous.open_time_ms:
111
+ raise DataContractError(f"Bar {bar.open_time_ms} was already fed to this session")
112
+ expected = previous.close_time_ms
113
+ if bar.open_time_ms != expected:
114
+ # A gap means the strategy would see a jump the market did not
115
+ # make. The host is expected to repair the window and retry rather
116
+ # than have this smoothed over.
117
+ raise DataContractError(
118
+ f"Bar {bar.open_time_ms} breaks continuity: expected open {expected}"
119
+ )
120
+ self._bars.append(bar)
121
+ self._series.push(bar)
122
+ # Trimming keeps the retained window bounded. `SeriesSet` holds its own
123
+ # incremental indicator state, which is append-only and unaffected.
124
+ if len(self._bars) > MAX_RETAINED_BARS:
125
+ del self._bars[: len(self._bars) - MAX_RETAINED_BARS]
126
+
127
+ def _context(self, bar: Bar, portfolio: PortfolioView) -> StrategyContext:
128
+ # `visible` is the full retained length: every bar in a live session is
129
+ # confirmed and in the past, so there is no future prefix to hide.
130
+ return StrategyContext(
131
+ kind="bar",
132
+ as_of_ms=bar.close_time_ms,
133
+ instrument_id=self._instrument_id,
134
+ interval="1m",
135
+ bar=bar,
136
+ market=MarketView(self._instrument_id, self._bars, len(self._bars), self._series.snapshot()),
137
+ portfolio=portfolio,
138
+ params=self._params,
139
+ indicators=IndicatorView(self._series.indicators, self._instrument_id, len(self._bars)),
140
+ )
141
+
142
+
143
+ def parse_portfolio(value: object, instrument_id: str) -> PortfolioView:
144
+ """Builds the strategy-visible account snapshot from the host's payload.
145
+
146
+ Fields are read by their exact documented names and every one is required.
147
+ Defaulting a missing balance to zero would let a protocol mismatch present
148
+ itself as a flat account, and a strategy would then open a position believing
149
+ it holds none.
150
+ """
151
+ if not isinstance(value, dict):
152
+ raise DataContractError("'portfolio' must be an object")
153
+ return PortfolioView(
154
+ cash_usdt=_required_float(value, "cashUsdt"),
155
+ equity_usdt=_required_float(value, "equityUsdt"),
156
+ used_margin_usdt=_required_float(value, "usedMarginUsdt"),
157
+ available_margin_usdt=_required_float(value, "availableMarginUsdt"),
158
+ positions=_parse_positions(value.get("positions"), instrument_id),
159
+ open_orders=_parse_open_orders(value.get("openOrders"), instrument_id),
160
+ )
161
+
162
+
163
+ def _parse_positions(value: object, instrument_id: str) -> tuple[Position, ...]:
164
+ if value is None:
165
+ return ()
166
+ if not isinstance(value, list):
167
+ raise DataContractError("'portfolio.positions' must be an array")
168
+ positions: list[Position] = []
169
+ for index, row in enumerate(value):
170
+ if not isinstance(row, dict):
171
+ raise DataContractError(f"portfolio.positions[{index}] must be an object")
172
+ side = str(row.get("side") or "")
173
+ if side not in (LONG, SHORT):
174
+ raise DataContractError(f"portfolio.positions[{index}].side must be '{LONG}' or '{SHORT}'")
175
+ quantity = _required_float(row, "quantity", f"portfolio.positions[{index}]")
176
+ if quantity <= 0:
177
+ # The exchange reports a closed position by omitting it, so a zero
178
+ # quantity here means the host built the payload wrong.
179
+ raise DataContractError(f"portfolio.positions[{index}].quantity must be greater than zero")
180
+ positions.append(
181
+ Position(
182
+ instrumentId=str(row.get("instrumentId") or instrument_id),
183
+ side=side,
184
+ quantity=quantity,
185
+ averageEntryPrice=_required_float(row, "averageEntryPrice", f"portfolio.positions[{index}]"),
186
+ markPrice=_required_float(row, "markPrice", f"portfolio.positions[{index}]"),
187
+ contractValue=_required_float(row, "contractValue", f"portfolio.positions[{index}]"),
188
+ notionalUsdt=_required_float(row, "notionalUsdt", f"portfolio.positions[{index}]"),
189
+ usedMarginUsdt=_required_float(row, "usedMarginUsdt", f"portfolio.positions[{index}]"),
190
+ leverage=_required_float(row, "leverage", f"portfolio.positions[{index}]"),
191
+ marginSafetyMultiplier=_optional_float(row.get("marginSafetyMultiplier"), 1.0),
192
+ unrealizedPnlUsdt=_required_float(row, "unrealizedPnlUsdt", f"portfolio.positions[{index}]"),
193
+ entryFeeUsdt=_optional_float(row.get("entryFeeUsdt"), 0.0),
194
+ stopLossPrice=_nullable_float(row.get("stopLossPrice")),
195
+ takeProfitPrice=_nullable_float(row.get("takeProfitPrice")),
196
+ openedAtMs=int(_optional_float(row.get("openedAtMs"), 0.0)),
197
+ updatedAtMs=int(_optional_float(row.get("updatedAtMs"), 0.0)),
198
+ )
199
+ )
200
+ return tuple(positions)
201
+
202
+
203
+ def _parse_open_orders(value: object, instrument_id: str) -> tuple[OpenOrder, ...]:
204
+ if value is None:
205
+ return ()
206
+ if not isinstance(value, list):
207
+ raise DataContractError("'portfolio.openOrders' must be an array")
208
+ orders: list[OpenOrder] = []
209
+ for index, row in enumerate(value):
210
+ if not isinstance(row, dict):
211
+ raise DataContractError(f"portfolio.openOrders[{index}] must be an object")
212
+ identifier = str(row.get("id") or "")
213
+ if not identifier:
214
+ # `cancel_order` references this id, so an order without one could be
215
+ # seen but never cancelled.
216
+ raise DataContractError(f"portfolio.openOrders[{index}].id is required")
217
+ orders.append(
218
+ OpenOrder(
219
+ id=identifier,
220
+ instrumentId=str(row.get("instrumentId") or instrument_id),
221
+ action=str(row.get("action") or ""),
222
+ quantity=_required_float(row, "quantity", f"portfolio.openOrders[{index}]"),
223
+ filledQuantity=_optional_float(row.get("filledQuantity"), 0.0),
224
+ status=str(row.get("status") or "open"),
225
+ price=_nullable_float(row.get("price")),
226
+ createdAtMs=int(_optional_float(row.get("createdAtMs"), 0.0)),
227
+ )
228
+ )
229
+ return tuple(orders)
230
+
231
+
232
+ def _required_float(row: dict[str, object], key: str, label: str = "portfolio") -> float:
233
+ if key not in row or row[key] is None:
234
+ raise DataContractError(f"{label}.{key} is required")
235
+ return _finite(row[key], f"{label}.{key}")
236
+
237
+
238
+ def _optional_float(value: object, fallback: float) -> float:
239
+ return fallback if value is None else _finite(value, "value")
240
+
241
+
242
+ def _nullable_float(value: object) -> float | None:
243
+ return None if value is None else _finite(value, "value")
244
+
245
+
246
+ def _finite(value: object, label: str) -> float:
247
+ try:
248
+ parsed = float(value) # type: ignore[arg-type]
249
+ except (TypeError, ValueError) as error:
250
+ raise DataContractError(f"{label} must be a number") from error
251
+ if parsed != parsed or parsed in (float("inf"), float("-inf")):
252
+ raise DataContractError(f"{label} must be finite")
253
+ return parsed
@@ -0,0 +1,193 @@
1
+ """Static source policy for user-authored strategies.
2
+
3
+ This runs before any strategy code is imported. It is defence in depth, not a
4
+ security boundary: the process already receives no credentials, no exchange
5
+ client, and no order API. Its job is to catch the mistakes and reaches that
6
+ would otherwise fail confusingly deep inside a run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import ast
12
+ from dataclasses import dataclass
13
+
14
+ ALLOWED_IMPORT_ROOTS = frozenset(
15
+ {"collections", "dataclasses", "math", "numpy", "pandas", "statistics", "typing"}
16
+ )
17
+
18
+ # Dynamic attribute access and host reach. `getattr` is rejected on purpose:
19
+ # probing for a field spelling hides a protocol mismatch that should fail loudly.
20
+ FORBIDDEN_NAMES = frozenset(
21
+ {
22
+ "getattr",
23
+ "setattr",
24
+ "delattr",
25
+ "dir",
26
+ "vars",
27
+ "globals",
28
+ "locals",
29
+ "eval",
30
+ "exec",
31
+ "compile",
32
+ "__import__",
33
+ "open",
34
+ "input",
35
+ "help",
36
+ "breakpoint",
37
+ }
38
+ )
39
+
40
+ REQUIRED_HANDLER = "on_bar"
41
+ OPTIONAL_HANDLERS = frozenset({"on_start"})
42
+ MAX_SOURCE_BYTES = 256 * 1024
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class PolicyViolation:
47
+ line: int
48
+ code: str
49
+ message: str
50
+
51
+ def as_dict(self) -> dict[str, object]:
52
+ return {"line": self.line, "code": self.code, "message": self.message}
53
+
54
+
55
+ def validate_source(source: str) -> list[PolicyViolation]:
56
+ """Returns every violation found, ordered by line, or an empty list."""
57
+ violations: list[PolicyViolation] = []
58
+ encoded = source.encode("utf-8")
59
+ if len(encoded) > MAX_SOURCE_BYTES:
60
+ return [
61
+ PolicyViolation(
62
+ 1,
63
+ "source_too_large",
64
+ f"Strategy source is {len(encoded)} bytes, above the {MAX_SOURCE_BYTES} byte limit",
65
+ )
66
+ ]
67
+
68
+ try:
69
+ tree = ast.parse(source)
70
+ except SyntaxError as error:
71
+ return [PolicyViolation(error.lineno or 1, "syntax_error", f"Syntax error: {error.msg}")]
72
+
73
+ violations.extend(_check_imports(tree))
74
+ violations.extend(_check_names(tree))
75
+ violations.extend(_check_handlers(tree))
76
+ violations.sort(key=lambda item: (item.line, item.code))
77
+ return violations
78
+
79
+
80
+ def _check_imports(tree: ast.AST) -> list[PolicyViolation]:
81
+ found: list[PolicyViolation] = []
82
+ for node in ast.walk(tree):
83
+ if isinstance(node, ast.Import):
84
+ for alias in node.names:
85
+ root = alias.name.split(".")[0]
86
+ if root not in ALLOWED_IMPORT_ROOTS:
87
+ found.append(
88
+ PolicyViolation(
89
+ node.lineno,
90
+ "forbidden_import",
91
+ f"Import of '{alias.name}' is not allowed. Allowed roots: "
92
+ + ", ".join(sorted(ALLOWED_IMPORT_ROOTS)),
93
+ )
94
+ )
95
+ elif isinstance(node, ast.ImportFrom):
96
+ # A bare relative import has no module name; it can only reach code
97
+ # outside the single-module strategy contract.
98
+ if node.level and node.level > 0:
99
+ found.append(
100
+ PolicyViolation(
101
+ node.lineno,
102
+ "forbidden_import",
103
+ "Relative imports are not allowed in a strategy module",
104
+ )
105
+ )
106
+ continue
107
+ root = (node.module or "").split(".")[0]
108
+ if root not in ALLOWED_IMPORT_ROOTS:
109
+ found.append(
110
+ PolicyViolation(
111
+ node.lineno,
112
+ "forbidden_import",
113
+ f"Import from '{node.module}' is not allowed. Allowed roots: "
114
+ + ", ".join(sorted(ALLOWED_IMPORT_ROOTS)),
115
+ )
116
+ )
117
+ return found
118
+
119
+
120
+ def _check_names(tree: ast.AST) -> list[PolicyViolation]:
121
+ found: list[PolicyViolation] = []
122
+ for node in ast.walk(tree):
123
+ if isinstance(node, ast.Name) and node.id in FORBIDDEN_NAMES:
124
+ found.append(
125
+ PolicyViolation(
126
+ node.lineno,
127
+ "forbidden_name",
128
+ f"'{node.id}' is not available to a strategy. Read documented fields directly.",
129
+ )
130
+ )
131
+ elif isinstance(node, ast.Attribute) and node.attr.startswith("__"):
132
+ found.append(
133
+ PolicyViolation(
134
+ node.lineno,
135
+ "dunder_access",
136
+ f"Access to '{node.attr}' is not allowed",
137
+ )
138
+ )
139
+ elif isinstance(node, (ast.AsyncFunctionDef, ast.Await, ast.AsyncFor, ast.AsyncWith)):
140
+ found.append(
141
+ PolicyViolation(
142
+ node.lineno,
143
+ "async_not_supported",
144
+ "Strategy handlers must be synchronous",
145
+ )
146
+ )
147
+ return found
148
+
149
+
150
+ def _check_handlers(tree: ast.Module) -> list[PolicyViolation]:
151
+ found: list[PolicyViolation] = []
152
+ handlers: dict[str, ast.FunctionDef] = {}
153
+ for node in tree.body:
154
+ if isinstance(node, ast.FunctionDef) and (
155
+ node.name == REQUIRED_HANDLER or node.name in OPTIONAL_HANDLERS
156
+ ):
157
+ handlers[node.name] = node
158
+
159
+ if REQUIRED_HANDLER not in handlers:
160
+ found.append(
161
+ PolicyViolation(
162
+ 1,
163
+ "missing_handler",
164
+ f"A strategy must define '{REQUIRED_HANDLER}(ctx)'",
165
+ )
166
+ )
167
+
168
+ for name, node in handlers.items():
169
+ arguments = node.args
170
+ positional = [*arguments.posonlyargs, *arguments.args]
171
+ if (
172
+ len(positional) != 1
173
+ or arguments.vararg is not None
174
+ or arguments.kwarg is not None
175
+ or arguments.kwonlyargs
176
+ ):
177
+ found.append(
178
+ PolicyViolation(
179
+ node.lineno,
180
+ "handler_signature",
181
+ f"'{name}' must take exactly one positional argument (ctx)",
182
+ )
183
+ )
184
+ for inner in ast.walk(node):
185
+ if isinstance(inner, (ast.Yield, ast.YieldFrom)):
186
+ found.append(
187
+ PolicyViolation(
188
+ inner.lineno,
189
+ "generator_handler",
190
+ f"'{name}' must return a decision, not yield",
191
+ )
192
+ )
193
+ return found