desic-okx-agent 0.2.1 → 0.3.1

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 (239) hide show
  1. package/README.en.md +90 -10
  2. package/README.md +76 -10
  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 +757 -26
  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.js +19 -22
  142. package/dist/setup/wizard.js.map +1 -1
  143. package/dist/strategy/constants.d.ts +23 -0
  144. package/dist/strategy/constants.js +24 -0
  145. package/dist/strategy/constants.js.map +1 -0
  146. package/dist/strategy/environment.d.ts +52 -0
  147. package/dist/strategy/environment.js +187 -0
  148. package/dist/strategy/environment.js.map +1 -0
  149. package/dist/strategy/instrument.d.ts +29 -0
  150. package/dist/strategy/instrument.js +39 -0
  151. package/dist/strategy/instrument.js.map +1 -0
  152. package/dist/strategy/optimize.d.ts +73 -0
  153. package/dist/strategy/optimize.js +113 -0
  154. package/dist/strategy/optimize.js.map +1 -0
  155. package/dist/strategy/parameter-space.d.ts +59 -0
  156. package/dist/strategy/parameter-space.js +221 -0
  157. package/dist/strategy/parameter-space.js.map +1 -0
  158. package/dist/strategy/python-bridge.d.ts +24 -0
  159. package/dist/strategy/python-bridge.js +114 -0
  160. package/dist/strategy/python-bridge.js.map +1 -0
  161. package/dist/strategy/schema.d.ts +11 -0
  162. package/dist/strategy/schema.js +94 -0
  163. package/dist/strategy/schema.js.map +1 -0
  164. package/dist/strategy/series-codec.d.ts +50 -0
  165. package/dist/strategy/series-codec.js +103 -0
  166. package/dist/strategy/series-codec.js.map +1 -0
  167. package/dist/strategy/service.d.ts +138 -0
  168. package/dist/strategy/service.js +745 -0
  169. package/dist/strategy/service.js.map +1 -0
  170. package/dist/strategy/settings.d.ts +162 -0
  171. package/dist/strategy/settings.js +243 -0
  172. package/dist/strategy/settings.js.map +1 -0
  173. package/dist/strategy/store.d.ts +96 -0
  174. package/dist/strategy/store.js +369 -0
  175. package/dist/strategy/store.js.map +1 -0
  176. package/dist/strategy/templates.d.ts +11 -0
  177. package/dist/strategy/templates.js +134 -0
  178. package/dist/strategy/templates.js.map +1 -0
  179. package/dist/strategy/types.d.ts +111 -0
  180. package/dist/strategy/types.js +2 -0
  181. package/dist/strategy/types.js.map +1 -0
  182. package/dist/tools/catalog.d.ts +16 -0
  183. package/dist/tools/catalog.js +118 -17
  184. package/dist/tools/catalog.js.map +1 -1
  185. package/dist/trade/service.d.ts +12 -0
  186. package/dist/trade/service.js +24 -7
  187. package/dist/trade/service.js.map +1 -1
  188. package/dist/tui/app.d.ts +23 -0
  189. package/dist/tui/app.js +322 -0
  190. package/dist/tui/app.js.map +1 -0
  191. package/dist/tui/commands.d.ts +70 -0
  192. package/dist/tui/commands.js +313 -0
  193. package/dist/tui/commands.js.map +1 -0
  194. package/dist/tui/entries.d.ts +17 -0
  195. package/dist/tui/entries.js +24 -0
  196. package/dist/tui/entries.js.map +1 -0
  197. package/dist/tui/execute.d.ts +26 -0
  198. package/dist/tui/execute.js +664 -0
  199. package/dist/tui/execute.js.map +1 -0
  200. package/dist/tui/history.d.ts +17 -0
  201. package/dist/tui/history.js +48 -0
  202. package/dist/tui/history.js.map +1 -0
  203. package/dist/tui/index.d.ts +8 -0
  204. package/dist/tui/index.js +48 -0
  205. package/dist/tui/index.js.map +1 -0
  206. package/dist/tui/line-editor.d.ts +44 -0
  207. package/dist/tui/line-editor.js +98 -0
  208. package/dist/tui/line-editor.js.map +1 -0
  209. package/dist/tui/progress.d.ts +23 -0
  210. package/dist/tui/progress.js +46 -0
  211. package/dist/tui/progress.js.map +1 -0
  212. package/dist/tui/settings-editor.d.ts +18 -0
  213. package/dist/tui/settings-editor.js +115 -0
  214. package/dist/tui/settings-editor.js.map +1 -0
  215. package/docs/live-trading.md +455 -0
  216. package/docs/strategy-research.md +608 -0
  217. package/package.json +10 -1
  218. package/python/desic_strategy/__init__.py +34 -0
  219. package/python/desic_strategy/actions.py +158 -0
  220. package/python/desic_strategy/context.py +171 -0
  221. package/python/desic_strategy/engine.py +614 -0
  222. package/python/desic_strategy/indicators.py +159 -0
  223. package/python/desic_strategy/live.py +253 -0
  224. package/python/desic_strategy/policy.py +193 -0
  225. package/python/desic_strategy/portfolio.py +152 -0
  226. package/python/desic_strategy/report.py +319 -0
  227. package/python/desic_strategy/runner.py +574 -0
  228. package/python/desic_strategy/timeframe.py +150 -0
  229. package/python/main.py +18 -0
  230. package/skills/okx-live-trading/SKILL.md +117 -0
  231. package/skills/okx-live-trading/agents/openai.yaml +9 -0
  232. package/skills/okx-live-trading/references/lifecycle.md +128 -0
  233. package/skills/okx-strategy-research/SKILL.md +113 -0
  234. package/skills/okx-strategy-research/agents/openai.yaml +9 -0
  235. package/skills/okx-strategy-research/references/execution-semantics.md +107 -0
  236. package/skills/okx-strategy-research/references/field-traps.md +142 -0
  237. package/skills/okx-strategy-research/references/python-api.md +125 -0
  238. package/skills/okx-strategy-research/references/tools-and-data.md +192 -0
  239. package/skills/okx-trading/SKILL.md +11 -10
@@ -0,0 +1,614 @@
1
+ """The backtest event loop.
2
+
3
+ Execution timing is the core contract: a decision is made at a confirmed
4
+ one-minute close and a market action fills at the *following* bar's open. A
5
+ limit action becomes a pending order at that open and is matched against later
6
+ bars using a deliberately conservative K-line estimate.
7
+
8
+ There is no historical order-book queue here, so a fill is never a promise
9
+ about real exchange latency, queue position, or liquidity.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ from .actions import (
17
+ CANCEL_ORDER,
18
+ CLOSE_LONG,
19
+ CLOSE_SHORT,
20
+ LIMIT,
21
+ NO_ACTION,
22
+ OPEN_LONG,
23
+ OPEN_SHORT,
24
+ Decision,
25
+ StrategyError,
26
+ )
27
+ from .context import MarketView, SeriesSet, StrategyContext, immutable_params
28
+ from .indicators import IndicatorView
29
+ from .portfolio import (
30
+ LONG,
31
+ SHORT,
32
+ AccountState,
33
+ Fill,
34
+ MutablePosition,
35
+ OpenOrder,
36
+ PortfolioView,
37
+ Position,
38
+ Trade,
39
+ )
40
+ from .timeframe import Bar
41
+
42
+ # A limit order may take at most this share of a bar's volume. Assuming more
43
+ # would let a backtest fill size the live book could not absorb.
44
+ BAR_VOLUME_FILL_CAP = 0.10
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Instrument:
49
+ """Contract specification, as reported by the exchange.
50
+
51
+ ``contract_value`` is base currency per contract and ``contract_multiplier``
52
+ scales it. Both are needed: OKX reports them separately, and folding the
53
+ multiplier into the value would hide a mismatch when only one changes.
54
+ """
55
+
56
+ inst_id: str
57
+ contract_value: float
58
+ lot_size: float
59
+ min_size: float
60
+ tick_size: float
61
+ contract_multiplier: float = 1.0
62
+
63
+ @property
64
+ def base_per_contract(self) -> float:
65
+ """Base-currency units one contract represents."""
66
+ return self.contract_value * self.contract_multiplier
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Costs:
71
+ taker_fee_rate: float = 0.0005
72
+ maker_fee_rate: float = 0.0002
73
+ entry_slippage_bps: float = 1.0
74
+ exit_slippage_bps: float = 1.0
75
+
76
+
77
+ @dataclass(frozen=True)
78
+ class Sizing:
79
+ """Host-owned position sizing. A strategy cannot influence these."""
80
+
81
+ leverage: float = 10.0
82
+ margin_safety_multiplier: float = 1.0
83
+ entry_budget_usdt: float | None = None
84
+ entry_budget_pct: float = 0.20
85
+
86
+
87
+ @dataclass(frozen=True)
88
+ class RunConfig:
89
+ instrument: Instrument
90
+ initial_equity_usdt: float = 10_000.0
91
+ costs: Costs = field(default_factory=Costs)
92
+ sizing: Sizing = field(default_factory=Sizing)
93
+ preload_bars: int = 2
94
+ close_at_end: bool = True
95
+ limit_order_ttl_bars: int = 60
96
+
97
+
98
+ @dataclass
99
+ class EquityPoint:
100
+ time_ms: int
101
+ equity_usdt: float
102
+ realized_cash_usdt: float
103
+ unrealized_pnl_usdt: float
104
+
105
+
106
+ @dataclass
107
+ class PendingAction:
108
+ decision: Decision
109
+ submitted_at_ms: int
110
+
111
+
112
+ @dataclass
113
+ class RunResult:
114
+ status: str
115
+ equity_curve: list[EquityPoint]
116
+ fills: list[Fill]
117
+ trades: list[Trade]
118
+ actions: list[dict[str, object]]
119
+ evaluation_bars: int
120
+ exposed_bars: int
121
+ margin_exhausted: bool
122
+
123
+
124
+ class Engine:
125
+ """Drives one strategy over one contiguous confirmed one-minute window."""
126
+
127
+ def __init__(self, config: RunConfig, handlers: dict[str, object], params: dict[str, object]) -> None:
128
+ self._config = config
129
+ self._handlers = handlers
130
+ self._params = immutable_params(params)
131
+ self._state = AccountState(cash_usdt=config.initial_equity_usdt)
132
+ self._sequence = 0
133
+ self._margin_exhausted = False
134
+
135
+ def run(
136
+ self,
137
+ bars: list[Bar],
138
+ intervals: list[str],
139
+ on_progress: object = None,
140
+ ) -> RunResult:
141
+ config = self._config
142
+ instrument = config.instrument
143
+ preload = config.preload_bars
144
+ if len(bars) <= preload:
145
+ raise StrategyError("The window must contain at least one evaluation bar after preload")
146
+
147
+ series = SeriesSet(instrument.inst_id, intervals)
148
+ equity_curve: list[EquityPoint] = []
149
+ actions_log: list[dict[str, object]] = []
150
+ pending: PendingAction | None = None
151
+ started = False
152
+ exposed_bars = 0
153
+ evaluation_bars = 0
154
+
155
+ for index, bar in enumerate(bars):
156
+ series.push(bar)
157
+ is_evaluation = index >= preload
158
+
159
+ if is_evaluation:
160
+ # A decision from the previous close acts at this open, before
161
+ # anything else can observe the resulting position.
162
+ if pending is not None:
163
+ self._apply_at_open(pending, bar)
164
+ pending = None
165
+ self._match_open_orders(bar)
166
+ self._apply_protective_exit(bar)
167
+ self._enforce_margin(bar)
168
+
169
+ unrealized = self._unrealized(bar.close)
170
+ equity_curve.append(
171
+ EquityPoint(
172
+ time_ms=bar.close_time_ms,
173
+ equity_usdt=self._state.cash_usdt + unrealized,
174
+ realized_cash_usdt=self._state.cash_usdt,
175
+ unrealized_pnl_usdt=unrealized,
176
+ )
177
+ )
178
+ evaluation_bars += 1
179
+ if self._state.position is not None:
180
+ exposed_bars += 1
181
+
182
+ # The final preloaded bar closes exactly at the evaluation start, so
183
+ # it supplies the first decision context while staying out of every
184
+ # reported statistic.
185
+ if not (is_evaluation or index + 1 == preload):
186
+ continue
187
+
188
+ context = self._context(bar, index + 1, bars, series)
189
+ if not started:
190
+ started = True
191
+ start_handler = self._handlers.get("on_start")
192
+ if callable(start_handler):
193
+ decision = start_handler(context)
194
+ self._require_no_action(decision)
195
+
196
+ handler = self._handlers.get("on_bar")
197
+ if not callable(handler):
198
+ raise StrategyError("A strategy must define on_bar(ctx)")
199
+ decision = handler(context)
200
+ decision = self._validate(decision)
201
+ if decision.action != NO_ACTION:
202
+ actions_log.append({"asOfMs": bar.close_time_ms, **decision.as_dict()})
203
+ if decision.action == CANCEL_ORDER:
204
+ self._cancel_order(decision.order_id)
205
+ else:
206
+ pending = PendingAction(decision, bar.close_time_ms)
207
+
208
+ if callable(on_progress) and (index + 1) % 512 == 0:
209
+ on_progress(index + 1, len(bars))
210
+
211
+ last_bar = bars[-1]
212
+ if config.close_at_end and self._state.position is not None:
213
+ self._close_position(last_bar.close_time_ms, self._exit_price(last_bar.close, self._state.position.side), "end_of_run")
214
+ if equity_curve:
215
+ equity_curve[-1].equity_usdt = self._state.cash_usdt
216
+ equity_curve[-1].realized_cash_usdt = self._state.cash_usdt
217
+ equity_curve[-1].unrealized_pnl_usdt = 0.0
218
+ # The forced close makes the final bar flat, so it no longer
219
+ # counts toward exposure.
220
+ exposed_bars = max(0, exposed_bars - 1)
221
+
222
+ if callable(on_progress):
223
+ on_progress(len(bars), len(bars))
224
+ return RunResult(
225
+ status="completed",
226
+ equity_curve=equity_curve,
227
+ fills=list(self._state.fills),
228
+ trades=list(self._state.trades),
229
+ actions=actions_log,
230
+ evaluation_bars=evaluation_bars,
231
+ exposed_bars=exposed_bars,
232
+ margin_exhausted=self._margin_exhausted,
233
+ )
234
+
235
+ # ----- context construction -------------------------------------------
236
+
237
+ def _context(self, bar: Bar, visible: int, bars: list[Bar], series: SeriesSet) -> StrategyContext:
238
+ instrument = self._config.instrument
239
+ market = MarketView(instrument.inst_id, bars, visible, series.snapshot())
240
+ return StrategyContext(
241
+ kind="bar",
242
+ as_of_ms=bar.close_time_ms,
243
+ instrument_id=instrument.inst_id,
244
+ interval="1m",
245
+ bar=bar,
246
+ market=market,
247
+ portfolio=self._portfolio_view(bar.close),
248
+ params=self._params,
249
+ indicators=IndicatorView(series.indicators, instrument.inst_id, visible),
250
+ )
251
+
252
+ def _portfolio_view(self, mark_price: float) -> PortfolioView:
253
+ instrument = self._config.instrument
254
+ state = self._state
255
+ positions: tuple[Position, ...] = ()
256
+ if state.position is not None:
257
+ item = state.position
258
+ positions = (
259
+ Position(
260
+ instrumentId=instrument.inst_id,
261
+ side=item.side,
262
+ quantity=item.quantity,
263
+ averageEntryPrice=item.average_entry_price,
264
+ markPrice=mark_price,
265
+ contractValue=instrument.contract_value,
266
+ notionalUsdt=item.notional(mark_price, instrument.base_per_contract),
267
+ usedMarginUsdt=item.used_margin_usdt,
268
+ leverage=item.leverage,
269
+ marginSafetyMultiplier=item.margin_safety_multiplier,
270
+ unrealizedPnlUsdt=item.unrealized(mark_price, instrument.base_per_contract),
271
+ entryFeeUsdt=item.entry_fee_usdt,
272
+ stopLossPrice=item.stop_loss_price,
273
+ takeProfitPrice=item.take_profit_price,
274
+ openedAtMs=item.opened_at_ms,
275
+ updatedAtMs=item.updated_at_ms,
276
+ ),
277
+ )
278
+ equity = state.equity(mark_price, instrument.base_per_contract)
279
+ return PortfolioView(
280
+ cash_usdt=state.cash_usdt,
281
+ equity_usdt=equity,
282
+ used_margin_usdt=state.used_margin_usdt,
283
+ available_margin_usdt=max(0.0, equity - state.used_margin_usdt),
284
+ positions=positions,
285
+ open_orders=tuple(state.open_orders),
286
+ recent_fills=tuple(state.fills[-50:]),
287
+ trades=tuple(state.trades),
288
+ )
289
+
290
+ # ----- decision handling ----------------------------------------------
291
+
292
+ def _validate(self, decision: object) -> Decision:
293
+ if not isinstance(decision, Decision):
294
+ raise StrategyError(
295
+ "A handler must return one of ctx.no_action, ctx.open_long, ctx.open_short, "
296
+ "ctx.close_long, ctx.close_short, or ctx.cancel_order"
297
+ )
298
+ if decision.action == CANCEL_ORDER:
299
+ known = {order.id for order in self._state.open_orders}
300
+ if decision.order_id not in known:
301
+ raise StrategyError(
302
+ f"cancel_order references '{decision.order_id}', which is not a current open order"
303
+ )
304
+ return decision
305
+
306
+ def _require_no_action(self, decision: object) -> None:
307
+ if not isinstance(decision, Decision) or decision.action != NO_ACTION:
308
+ raise StrategyError("on_start must return ctx.no_action(...)")
309
+
310
+ def _apply_at_open(self, pending: PendingAction, bar: Bar) -> None:
311
+ decision = pending.decision
312
+ if decision.execution.order_type == LIMIT:
313
+ self._register_limit_order(decision, bar)
314
+ return
315
+ if decision.action in (OPEN_LONG, OPEN_SHORT):
316
+ side = LONG if decision.action == OPEN_LONG else SHORT
317
+ self._open(side, self._entry_price(bar.open, side), bar.close_time_ms, decision, taker=True)
318
+ elif decision.action in (CLOSE_LONG, CLOSE_SHORT):
319
+ side = LONG if decision.action == CLOSE_LONG else SHORT
320
+ if self._state.position is not None and self._state.position.side == side:
321
+ self._close_position(bar.close_time_ms, self._exit_price(bar.open, side), "strategy_close")
322
+
323
+ def _register_limit_order(self, decision: Decision, bar: Bar) -> None:
324
+ quantity = (
325
+ self._entry_quantity(bar.open)
326
+ if decision.action in (OPEN_LONG, OPEN_SHORT)
327
+ else (self._state.position.quantity if self._state.position else 0.0)
328
+ )
329
+ if quantity <= 0:
330
+ return
331
+ self._sequence += 1
332
+ self._state.open_orders.append(
333
+ OpenOrder(
334
+ id=f"ord-{self._sequence}",
335
+ instrumentId=self._config.instrument.inst_id,
336
+ action=decision.action,
337
+ quantity=quantity,
338
+ filledQuantity=0.0,
339
+ status="open",
340
+ price=decision.execution.limit_price,
341
+ createdAtMs=bar.open_time_ms,
342
+ )
343
+ )
344
+
345
+ def _cancel_order(self, order_id: str | None) -> None:
346
+ self._state.open_orders = [order for order in self._state.open_orders if order.id != order_id]
347
+
348
+ def _match_open_orders(self, bar: Bar) -> None:
349
+ """Conservative K-line matching.
350
+
351
+ A buy requires the bar to trade strictly below its limit and a sell
352
+ strictly above, and any single bar can absorb at most
353
+ ``BAR_VOLUME_FILL_CAP`` of its own volume.
354
+ """
355
+ if not self._state.open_orders:
356
+ return
357
+ remaining: list[OpenOrder] = []
358
+ for order in self._state.open_orders:
359
+ price = order.price
360
+ if price is None:
361
+ remaining.append(order)
362
+ continue
363
+ buying = order.action in (OPEN_LONG, CLOSE_SHORT)
364
+ reached = bar.low < price if buying else bar.high > price
365
+ expired = bar.open_time_ms - order.createdAtMs >= self._config.limit_order_ttl_bars * 60_000
366
+ if not reached:
367
+ if not expired:
368
+ remaining.append(order)
369
+ continue
370
+ capacity = bar.volume * BAR_VOLUME_FILL_CAP
371
+ fillable = min(order.quantity - order.filledQuantity, max(0.0, capacity))
372
+ fillable = self._align_lot(fillable)
373
+ if fillable <= 0:
374
+ if not expired:
375
+ remaining.append(order)
376
+ continue
377
+ if order.action in (OPEN_LONG, OPEN_SHORT):
378
+ side = LONG if order.action == OPEN_LONG else SHORT
379
+ self._open_quantity(side, price, bar.close_time_ms, fillable, None, taker=False)
380
+ else:
381
+ side = LONG if order.action == CLOSE_LONG else SHORT
382
+ if self._state.position is not None and self._state.position.side == side:
383
+ self._reduce_position(bar.close_time_ms, price, fillable, "strategy_close", taker=False)
384
+ filled = order.filledQuantity + fillable
385
+ if filled < order.quantity and not expired:
386
+ remaining.append(
387
+ OpenOrder(
388
+ id=order.id,
389
+ instrumentId=order.instrumentId,
390
+ action=order.action,
391
+ quantity=order.quantity,
392
+ filledQuantity=filled,
393
+ status="partially_filled",
394
+ price=order.price,
395
+ createdAtMs=order.createdAtMs,
396
+ )
397
+ )
398
+ self._state.open_orders = remaining
399
+
400
+ # ----- position mechanics ---------------------------------------------
401
+
402
+ def _entry_quantity(self, price: float) -> float:
403
+ instrument = self._config.instrument
404
+ sizing = self._config.sizing
405
+ equity = self._state.equity(price, instrument.base_per_contract)
406
+ budget = sizing.entry_budget_usdt or equity * sizing.entry_budget_pct
407
+ budget = min(budget, max(0.0, equity - self._state.used_margin_usdt))
408
+ if budget <= 0:
409
+ return 0.0
410
+ notional = budget * sizing.leverage / sizing.margin_safety_multiplier
411
+ quantity = notional / (price * instrument.base_per_contract)
412
+ return self._align_lot(quantity)
413
+
414
+ def _align_lot(self, quantity: float) -> float:
415
+ instrument = self._config.instrument
416
+ if instrument.lot_size <= 0:
417
+ return max(0.0, quantity)
418
+ steps = int(quantity / instrument.lot_size + 1e-9)
419
+ aligned = steps * instrument.lot_size
420
+ if aligned < instrument.min_size:
421
+ return 0.0
422
+ return aligned
423
+
424
+ def _open(self, side: str, price: float, now_ms: int, decision: Decision, taker: bool) -> None:
425
+ quantity = self._entry_quantity(price)
426
+ if quantity <= 0:
427
+ return
428
+ self._open_quantity(side, price, now_ms, quantity, decision, taker=taker)
429
+
430
+ def _open_quantity(
431
+ self,
432
+ side: str,
433
+ price: float,
434
+ now_ms: int,
435
+ quantity: float,
436
+ decision: Decision | None,
437
+ taker: bool,
438
+ ) -> None:
439
+ instrument = self._config.instrument
440
+ sizing = self._config.sizing
441
+ existing = self._state.position
442
+ if existing is not None and existing.side != side:
443
+ # Reversal is explicit: a strategy must close before opening the
444
+ # opposite side, so this is a contract violation, not a flip.
445
+ raise StrategyError(
446
+ f"Cannot open {side} while a {existing.side} position is open; close it first"
447
+ )
448
+ notional = quantity * instrument.base_per_contract * price
449
+ margin = notional / sizing.leverage * sizing.margin_safety_multiplier
450
+ fee = notional * (self._config.costs.taker_fee_rate if taker else self._config.costs.maker_fee_rate)
451
+ if margin > max(0.0, self._state.equity(price, instrument.base_per_contract) - self._state.used_margin_usdt):
452
+ return
453
+ self._state.cash_usdt -= fee
454
+ self._state.used_margin_usdt += margin
455
+ protection = decision.protection if decision is not None else None
456
+ if existing is None:
457
+ self._state.position = MutablePosition(
458
+ side=side,
459
+ quantity=quantity,
460
+ average_entry_price=price,
461
+ used_margin_usdt=margin,
462
+ leverage=sizing.leverage,
463
+ margin_safety_multiplier=sizing.margin_safety_multiplier,
464
+ entry_fee_usdt=fee,
465
+ stop_loss_price=protection.stop_loss_price if protection else None,
466
+ take_profit_price=protection.take_profit_price if protection else None,
467
+ opened_at_ms=now_ms,
468
+ updated_at_ms=now_ms,
469
+ )
470
+ else:
471
+ total = existing.quantity + quantity
472
+ existing.average_entry_price = (
473
+ existing.average_entry_price * existing.quantity + price * quantity
474
+ ) / total
475
+ existing.quantity = total
476
+ existing.used_margin_usdt += margin
477
+ existing.entry_fee_usdt += fee
478
+ existing.updated_at_ms = now_ms
479
+ self._record_fill(
480
+ action=OPEN_LONG if side == LONG else OPEN_SHORT,
481
+ quantity=quantity,
482
+ price=price,
483
+ fee=fee,
484
+ now_ms=now_ms,
485
+ )
486
+
487
+ def _reduce_position(
488
+ self,
489
+ now_ms: int,
490
+ price: float,
491
+ quantity: float,
492
+ reason: str,
493
+ taker: bool = True,
494
+ ) -> None:
495
+ position = self._state.position
496
+ if position is None:
497
+ return
498
+ instrument = self._config.instrument
499
+ quantity = min(quantity, position.quantity)
500
+ if quantity <= 0:
501
+ return
502
+ notional = quantity * instrument.base_per_contract * price
503
+ fee = notional * (self._config.costs.taker_fee_rate if taker else self._config.costs.maker_fee_rate)
504
+ delta = price - position.average_entry_price
505
+ if position.side == SHORT:
506
+ delta = -delta
507
+ realized = delta * quantity * instrument.base_per_contract
508
+ share = quantity / position.quantity
509
+ released = position.used_margin_usdt * share
510
+
511
+ self._state.cash_usdt += realized - fee
512
+ self._state.used_margin_usdt = max(0.0, self._state.used_margin_usdt - released)
513
+ self._record_fill(
514
+ action=CLOSE_LONG if position.side == LONG else CLOSE_SHORT,
515
+ quantity=quantity,
516
+ price=price,
517
+ fee=fee,
518
+ now_ms=now_ms,
519
+ )
520
+ self._sequence += 1
521
+ self._state.trades.append(
522
+ Trade(
523
+ id=f"trade-{self._sequence}",
524
+ instrumentId=instrument.inst_id,
525
+ side=position.side,
526
+ quantity=quantity,
527
+ entryPrice=position.average_entry_price,
528
+ exitPrice=price,
529
+ usedMarginUsdt=released,
530
+ leverage=position.leverage,
531
+ realizedPnlUsdt=realized,
532
+ feesUsdt=fee + position.entry_fee_usdt * share,
533
+ openedAtMs=position.opened_at_ms,
534
+ closedAtMs=now_ms,
535
+ )
536
+ )
537
+ position.quantity -= quantity
538
+ position.used_margin_usdt -= released
539
+ position.entry_fee_usdt *= 1 - share
540
+ position.updated_at_ms = now_ms
541
+ if position.quantity <= 1e-12:
542
+ self._state.position = None
543
+ # A full close removes attached protection automatically.
544
+ self._state.open_orders = [
545
+ order for order in self._state.open_orders if order.action in (OPEN_LONG, OPEN_SHORT)
546
+ ]
547
+
548
+ def _close_position(self, now_ms: int, price: float, reason: str) -> None:
549
+ position = self._state.position
550
+ if position is None:
551
+ return
552
+ self._reduce_position(now_ms, price, position.quantity, reason)
553
+
554
+ def _apply_protective_exit(self, bar: Bar) -> None:
555
+ position = self._state.position
556
+ if position is None:
557
+ return
558
+ stop = position.stop_loss_price
559
+ take = position.take_profit_price
560
+ if position.side == LONG:
561
+ # When both could trigger inside one bar, assume the adverse one:
562
+ # intrabar order is unknowable from OHLC alone.
563
+ if stop is not None and bar.low <= stop:
564
+ self._close_position(bar.close_time_ms, stop, "stop_loss")
565
+ return
566
+ if take is not None and bar.high >= take:
567
+ self._close_position(bar.close_time_ms, take, "take_profit")
568
+ else:
569
+ if stop is not None and bar.high >= stop:
570
+ self._close_position(bar.close_time_ms, stop, "stop_loss")
571
+ return
572
+ if take is not None and bar.low <= take:
573
+ self._close_position(bar.close_time_ms, take, "take_profit")
574
+
575
+ def _enforce_margin(self, bar: Bar) -> None:
576
+ position = self._state.position
577
+ if position is None:
578
+ return
579
+ instrument = self._config.instrument
580
+ adverse = bar.low if position.side == LONG else bar.high
581
+ equity = self._state.cash_usdt + position.unrealized(adverse, instrument.base_per_contract)
582
+ if equity <= 0:
583
+ self._margin_exhausted = True
584
+ self._close_position(bar.close_time_ms, adverse, "margin_exhaustion")
585
+
586
+ def _record_fill(self, action: str, quantity: float, price: float, fee: float, now_ms: int) -> None:
587
+ instrument = self._config.instrument
588
+ self._sequence += 1
589
+ self._state.fills.append(
590
+ Fill(
591
+ id=f"fill-{self._sequence}",
592
+ orderId=f"ord-{self._sequence}",
593
+ instrumentId=instrument.inst_id,
594
+ action=action,
595
+ quantity=quantity,
596
+ price=price,
597
+ notionalUsdt=quantity * instrument.base_per_contract * price,
598
+ feeUsdt=fee,
599
+ filledAtMs=now_ms,
600
+ )
601
+ )
602
+
603
+ def _entry_price(self, price: float, side: str) -> float:
604
+ slip = self._config.costs.entry_slippage_bps / 10_000
605
+ return price * (1 + slip) if side == LONG else price * (1 - slip)
606
+
607
+ def _exit_price(self, price: float, side: str) -> float:
608
+ slip = self._config.costs.exit_slippage_bps / 10_000
609
+ return price * (1 - slip) if side == LONG else price * (1 + slip)
610
+
611
+ def _unrealized(self, mark_price: float) -> float:
612
+ if self._state.position is None:
613
+ return 0.0
614
+ return self._state.position.unrealized(mark_price, self._config.instrument.base_per_contract)