desic-okx-agent 0.3.0 → 0.3.2

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 (38) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/dist/cli/index.js +0 -0
  4. package/dist/report/fetch.d.ts +32 -0
  5. package/dist/report/fetch.js +42 -0
  6. package/dist/report/fetch.js.map +1 -1
  7. package/dist/strategy/schema.d.ts +7 -5
  8. package/dist/strategy/schema.js +7 -4
  9. package/dist/strategy/schema.js.map +1 -1
  10. package/dist/strategy/series-codec.d.ts +50 -0
  11. package/dist/strategy/series-codec.js +103 -0
  12. package/dist/strategy/series-codec.js.map +1 -0
  13. package/dist/strategy/service.js +4 -1
  14. package/dist/strategy/service.js.map +1 -1
  15. package/dist/strategy/store.d.ts +12 -1
  16. package/dist/strategy/store.js +67 -34
  17. package/dist/strategy/store.js.map +1 -1
  18. package/dist/tools/catalog.js +20 -1
  19. package/dist/tools/catalog.js.map +1 -1
  20. package/dist/tui/app.js +106 -35
  21. package/dist/tui/app.js.map +1 -1
  22. package/dist/tui/commands.d.ts +33 -0
  23. package/dist/tui/commands.js +59 -14
  24. package/dist/tui/commands.js.map +1 -1
  25. package/dist/tui/file-completion.d.ts +13 -0
  26. package/dist/tui/file-completion.js +57 -0
  27. package/dist/tui/file-completion.js.map +1 -0
  28. package/docs/strategy-research.md +27 -3
  29. package/package.json +1 -1
  30. package/python/desic_strategy/context.py +39 -14
  31. package/python/desic_strategy/engine.py +4 -1
  32. package/python/desic_strategy/live.py +1 -1
  33. package/python/desic_strategy/policy.py +51 -0
  34. package/python/desic_strategy/runner.py +36 -9
  35. package/python/desic_strategy/timeframe.py +17 -0
  36. package/skills/okx-strategy-research/SKILL.md +23 -0
  37. package/skills/okx-strategy-research/references/python-api.md +11 -0
  38. package/skills/okx-strategy-research/references/tools-and-data.md +7 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-completion.js","sourceRoot":"","sources":["../../src/tui/file-completion.ts"],"names":[],"mappings":"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"}
@@ -199,9 +199,7 @@ desic-okx strategy validate --file ema.py
199
199
 
200
200
  ### 多周期
201
201
 
202
- ```bash
203
- desic-okx strategy backtest --file s.py --inst BTC-USDT-SWAP --intervals 30m,1H
204
- ```
202
+ **直接写就行,不需要声明。** 引擎会读你的源码,把 `ctx.market.bars(..., "30m")` 里出现的周期自动加进来:
205
203
 
206
204
  ```python
207
205
  buckets = ctx.market.bars(ctx.instrument_id, "30m", lookback=2)
@@ -209,8 +207,32 @@ if not buckets[-1].confirmed:
209
207
  return ctx.no_action("等 30m 收线")
210
208
  ```
211
209
 
210
+ ```bash
211
+ desic-okx strategy backtest --file s.py --inst BTC-USDT-SWAP # 30m 自动派生
212
+ ```
213
+
214
+ 报告的 `assumptions.intervals` 记录的是**实际派生的**那些,不是你请求的 —— 所以你能核对引擎理解得对不对。
215
+
216
+ `--intervals` 仍然保留,用于**周期名是算出来的**情况 —— 那种源码里读不出来:
217
+
218
+ ```python
219
+ chosen = ctx.params.get("tf", "15m") # 读不出来
220
+ ctx.market.bars(ctx.instrument_id, chosen) # 需要 --intervals 15m
221
+ ```
222
+
212
223
  注意:`ctx.indicators` 只接受 `1m`。高周期的当前桶在确认前还可能被修正,用它做增量指标状态,缓存值会悄悄依赖于"第一次算的时间点"。
213
224
 
225
+ ### 只要尾部就一定传 `lookback`
226
+
227
+ `ctx.market.bars()` 的开销取决于你要多少根,**传了 `lookback` 就只付那么多**。省略它会返回至今全部可见 K 线,而这个方法每根收线都会被调一次 —— 长窗口下这是"几秒"和"几分钟"的差别。
228
+
229
+ ```python
230
+ bars = ctx.market.bars(ctx.instrument_id, "1m", lookback=period + 3) # 只付 period+3
231
+ bars = ctx.market.bars(ctx.instrument_id, "1m") # 26 万根全拿
232
+ ```
233
+
234
+ 只用 `ctx.indicators.*` 的策略完全不碰这条路径 —— 指标是增量维护的。
235
+
214
236
  ### 看不到未来
215
237
 
216
238
  `ctx.market.bars()` 只暴露到当前可见长度,物理上取不到之后的 K 线。任何可见 K 线的 `closeTime ≤ ctx.as_of_ms`。测试里专门构造了"能看到未来就会盈利"的策略,断言它无法盈利。
@@ -509,6 +531,8 @@ MCP 工具(`desic-okx setup` 会自动为 Codex 和 Claude Code 装好):
509
531
  | `strategy_run_optimize` | 参数搜索,入队 |
510
532
  | `strategy_get_optimization` | 排名后的候选表,含训练/验证指标 |
511
533
  | `strategy_compare_runs` | 多运行对比,含差异参数与警告 |
534
+ | `strategy_open_report` | 生成单次运行的 HTML 报告并在浏览器打开 |
535
+ | `strategy_open_comparison` | 同上,多运行对比,叠加权益曲线 |
512
536
  | `strategy_get_run` | 状态、进度、汇总指标 |
513
537
  | `strategy_get_run_equity` / `_trades` / `_actions` | 分页读明细 |
514
538
  | `strategy_settings` / `_update` | 读写回测参数 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "desic-okx-agent",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Independent Desic runtime, MCP server, CLI, and agent skills for OKX",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,19 +24,24 @@ class ContextError(Exception):
24
24
  class MarketView:
25
25
  """Point-in-time K-line access across derived timeframes."""
26
26
 
27
- __slots__ = ("_aggregates", "_instrument_id", "_minutes", "_visible")
27
+ __slots__ = ("_aggregates", "_aggregators", "_instrument_id", "_minutes", "_visible")
28
28
 
29
29
  def __init__(
30
30
  self,
31
31
  instrument_id: str,
32
32
  minutes: list[Bar],
33
33
  visible: int,
34
- aggregates: Mapping[str, tuple[Bar, ...]],
34
+ aggregates: Mapping[str, tuple[Bar, ...]] | None = None,
35
+ aggregators: Mapping[str, TimeframeAggregator] | None = None,
35
36
  ) -> None:
36
37
  self._instrument_id = instrument_id
37
38
  self._minutes = minutes
38
39
  self._visible = visible
39
- self._aggregates = aggregates
40
+ # Either form works. `aggregators` applies a lookback before the tuple is built,
41
+ # which is the cheap path; `aggregates` keeps a caller that already holds
42
+ # snapshots working unchanged.
43
+ self._aggregates = aggregates or {}
44
+ self._aggregators = aggregators or {}
40
45
 
41
46
  def bars(self, instrument_id: str, interval: str = "1m", lookback: int | None = None) -> tuple[Bar, ...]:
42
47
  if instrument_id != self._instrument_id:
@@ -44,18 +49,28 @@ class MarketView:
44
49
  f"Market data is available for {self._instrument_id}, not {instrument_id}"
45
50
  )
46
51
  interval_ms(interval)
47
- if interval == "1m":
48
- series: tuple[Bar, ...] = tuple(self._minutes[: self._visible])
49
- else:
50
- found = self._aggregates.get(interval)
51
- if found is None:
52
- raise ContextError(f"Series '{interval}' is not available in this run")
53
- series = found
54
- if lookback is None:
55
- return series
56
- if not isinstance(lookback, int) or isinstance(lookback, bool) or lookback <= 0:
52
+ if lookback is not None and (
53
+ not isinstance(lookback, int) or isinstance(lookback, bool) or lookback <= 0
54
+ ):
57
55
  raise ValueError("lookback must be a positive integer")
58
- return series[-lookback:]
56
+ if interval == "1m":
57
+ # Sliced before the copy, not after. `_minutes` holds the whole run, so
58
+ # copying the visible prefix and then discarding all but the tail costs one
59
+ # copy of everything seen so far, on every bar — quadratic in the window
60
+ # length. Measured on a 260k-bar run: a strategy asking for 17 bars here
61
+ # took 576 us/bar, against 16 us/bar for one that never called this method.
62
+ stop = self._visible
63
+ start = 0 if lookback is None else max(0, stop - lookback)
64
+ return tuple(self._minutes[start:stop])
65
+ # The aggregator path slices before building the tuple, so a lookback costs the
66
+ # window rather than every bucket accumulated so far.
67
+ aggregator = self._aggregators.get(interval)
68
+ if aggregator is not None:
69
+ return aggregator.tail(lookback)
70
+ found = self._aggregates.get(interval)
71
+ if found is None:
72
+ raise ContextError(f"Series '{interval}' is not available in this run")
73
+ return found if lookback is None else found[-lookback:]
59
74
 
60
75
 
61
76
  class StrategyContext:
@@ -162,3 +177,13 @@ class SeriesSet:
162
177
 
163
178
  def snapshot(self) -> dict[str, tuple[Bar, ...]]:
164
179
  return {interval: agg.snapshot() for interval, agg in self._aggregators.items()}
180
+
181
+ @property
182
+ def aggregators(self) -> Mapping[str, TimeframeAggregator]:
183
+ """The live aggregators, for a reader that wants only a tail.
184
+
185
+ Handed over instead of a snapshot so the slice happens before the tuple is
186
+ built. Materializing every bucket on every bar is the cost this avoids; the
187
+ aggregators are only read through `tail`, which returns a new tuple.
188
+ """
189
+ return self._aggregators
@@ -236,7 +236,10 @@ class Engine:
236
236
 
237
237
  def _context(self, bar: Bar, visible: int, bars: list[Bar], series: SeriesSet) -> StrategyContext:
238
238
  instrument = self._config.instrument
239
- market = MarketView(instrument.inst_id, bars, visible, series.snapshot())
239
+ # Aggregators rather than snapshots: this runs once per bar, and materializing
240
+ # every derived bucket here cost 14.5 us/bar at 4,000 buckets against 1.9 us/bar
241
+ # for the tail a strategy actually reads.
242
+ market = MarketView(instrument.inst_id, bars, visible, aggregators=series.aggregators)
240
243
  return StrategyContext(
241
244
  kind="bar",
242
245
  as_of_ms=bar.close_time_ms,
@@ -133,7 +133,7 @@ class LiveSession:
133
133
  instrument_id=self._instrument_id,
134
134
  interval="1m",
135
135
  bar=bar,
136
- market=MarketView(self._instrument_id, self._bars, len(self._bars), self._series.snapshot()),
136
+ market=MarketView(self._instrument_id, self._bars, len(self._bars), aggregators=self._series.aggregators),
137
137
  portfolio=portfolio,
138
138
  params=self._params,
139
139
  indicators=IndicatorView(self._series.indicators, self._instrument_id, len(self._bars)),
@@ -191,3 +191,54 @@ def _check_handlers(tree: ast.Module) -> list[PolicyViolation]:
191
191
  )
192
192
  )
193
193
  return found
194
+
195
+
196
+ def discover_intervals(source: str) -> tuple[list[str], bool]:
197
+ """Finds the higher timeframes a strategy reads, from the source alone.
198
+
199
+ Higher timeframes are aggregated incrementally: an aggregator has to see every
200
+ one-minute bar from the start, so one cannot be created partway through a run.
201
+ Declaring them was therefore a real requirement — but the declaration duplicated
202
+ something the source already states, and getting it wrong failed at bar one with
203
+ "Series '15m' is not available in this run".
204
+
205
+ Returns the intervals found as literals, and whether any call could not be read
206
+ statically. A `bars(id, chosen)` where `chosen` is computed cannot be resolved
207
+ here, so the caller keeps the explicit list as the way to cover that.
208
+
209
+ Deliberately syntactic. It matches `.bars(...)` by attribute name and takes only
210
+ string literals in the interval position, so it never runs strategy code and
211
+ cannot be tricked into reporting an interval that will not be requested. Over-
212
+ reporting is harmless (an unused aggregator costs one pass over the bars);
213
+ under-reporting is what the returned flag warns about.
214
+ """
215
+ try:
216
+ tree = ast.parse(source)
217
+ except SyntaxError:
218
+ return [], False
219
+
220
+ found: set[str] = set()
221
+ dynamic = False
222
+ for node in ast.walk(tree):
223
+ if not isinstance(node, ast.Call):
224
+ continue
225
+ function = node.func
226
+ if not isinstance(function, ast.Attribute) or function.attr != "bars":
227
+ continue
228
+ argument = _interval_argument(node)
229
+ if argument is None:
230
+ continue
231
+ if isinstance(argument, ast.Constant) and isinstance(argument.value, str):
232
+ found.add(argument.value)
233
+ else:
234
+ dynamic = True
235
+ return sorted(item for item in found if item != "1m"), dynamic
236
+
237
+
238
+ def _interval_argument(node: ast.Call) -> ast.expr | None:
239
+ """The interval argument of a `bars` call, positional or by keyword."""
240
+ for keyword in node.keywords:
241
+ if keyword.arg == "interval":
242
+ return keyword.value
243
+ # bars(instrument_id, interval, ...) — the instrument is first.
244
+ return node.args[1] if len(node.args) > 1 else None
@@ -21,7 +21,7 @@ from .actions import StrategyError
21
21
  from .engine import Costs, Engine, Instrument, RunConfig, Sizing
22
22
  from .indicators import IndicatorError
23
23
  from .live import LiveSession, parse_portfolio
24
- from .policy import validate_source
24
+ from .policy import discover_intervals, validate_source
25
25
  from .report import build_report, calculate_metrics
26
26
  from .timeframe import Bar, DataContractError, ONE_MINUTE_MS, interval_ms
27
27
 
@@ -170,7 +170,7 @@ def _backtest(request: dict[str, Any]) -> dict[str, Any]:
170
170
  bars = _parse_bars(request.get("bars"))
171
171
  config = _parse_config(request)
172
172
  params = dict(request.get("params") or {})
173
- intervals = _parse_intervals(request.get("intervals"))
173
+ intervals = _parse_intervals(request.get("intervals"), source)
174
174
  handlers = _load_handlers(source)
175
175
 
176
176
  total = len(bars)
@@ -182,6 +182,10 @@ def _backtest(request: dict[str, Any]) -> dict[str, Any]:
182
182
  "protocol": PROTOCOL_VERSION,
183
183
  "barCount": total,
184
184
  "preloadBars": config.preload_bars,
185
+ # Reported because the engine resolves this, not the caller: intervals read from
186
+ # the source are added to whatever was requested, so the request is no longer
187
+ # what the run used. A report naming the request would state the wrong thing.
188
+ "intervals": intervals,
185
189
  "report": report,
186
190
  }
187
191
 
@@ -224,7 +228,7 @@ def _live(request: dict[str, Any]) -> int:
224
228
  _emit({"ok": False, "error": {"code": "bad_request", "message": "instrument.instId is required"}})
225
229
  return 2
226
230
  instrument_id = str(instrument_raw["instId"])
227
- intervals = _parse_intervals(request.get("intervals"))
231
+ intervals = _parse_intervals(request.get("intervals"), source)
228
232
  handlers = _load_handlers(source)
229
233
  session = LiveSession(instrument_id, handlers, dict(request.get("params") or {}), intervals)
230
234
 
@@ -309,7 +313,7 @@ def _optimize(request: dict[str, Any]) -> dict[str, Any]:
309
313
 
310
314
  bars = _parse_bars(request.get("bars"))
311
315
  base_config = _parse_config(request)
312
- intervals = _parse_intervals(request.get("intervals"))
316
+ intervals = _parse_intervals(request.get("intervals"), source)
313
317
  handlers = _load_handlers(source)
314
318
  segments = _parse_segments(request.get("segments"), len(bars))
315
319
  candidates = _parse_candidates(request.get("candidates"))
@@ -470,17 +474,40 @@ def _parse_bars(value: Any) -> list[Bar]:
470
474
  return bars
471
475
 
472
476
 
473
- def _parse_intervals(value: Any) -> list[str]:
474
- if value is None:
475
- return ["1m"]
476
- if not isinstance(value, list):
477
+ def _parse_intervals(value: Any, source: str | None = None) -> list[str]:
478
+ """Resolves which timeframes to aggregate for a run.
479
+
480
+ Anything the request names is honoured, and anything the source visibly reads is
481
+ added to it. Requiring the declaration meant a strategy calling
482
+ `ctx.market.bars(id, "15m")` failed on its first bar over a fact its own source
483
+ already stated. Reading it instead removes a step that could only be got wrong.
484
+
485
+ The request still matters. A timeframe reached through a computed name cannot be
486
+ found by reading the source, so naming it explicitly remains the way to cover
487
+ that; and an interval named but never used costs only one pass over the bars.
488
+ """
489
+ if value is not None and not isinstance(value, list):
477
490
  raise DataContractError("'intervals' must be an array of interval names")
478
491
  intervals = ["1m"]
479
- for item in value:
492
+ for item in value or []:
480
493
  name = str(item)
481
494
  interval_ms(name)
482
495
  if name not in intervals:
483
496
  intervals.append(name)
497
+ if source is not None:
498
+ discovered, _ = discover_intervals(source)
499
+ for name in discovered:
500
+ # Validated like a declared one, but named as read from the source: the
501
+ # strategy would reach that line and fail anyway, so failing now with the
502
+ # reason is strictly better than failing on bar one without it.
503
+ try:
504
+ interval_ms(name)
505
+ except DataContractError as error:
506
+ raise DataContractError(
507
+ f"The strategy reads interval '{name}', which cannot be aggregated. {error}"
508
+ ) from error
509
+ if name not in intervals:
510
+ intervals.append(name)
484
511
  return intervals
485
512
 
486
513
 
@@ -139,6 +139,23 @@ class TimeframeAggregator:
139
139
  return tuple(self._completed)
140
140
  return (*self._completed, self._current)
141
141
 
142
+ def tail(self, lookback: int | None) -> tuple[Bar, ...]:
143
+ """The last `lookback` buckets, or all of them when it is None.
144
+
145
+ Sliced before the tuple is built. `snapshot()` copies every accumulated bucket,
146
+ and it is called once per one-minute bar, so a 180-day run over 15m and 1H
147
+ rebuilt ~21,600 elements 260,000 times. Measured at 4,000 buckets: 14.5 us/bar
148
+ for the full snapshot against 1.9 us/bar for a 96-bucket tail.
149
+ """
150
+ if lookback is None:
151
+ return self.snapshot()
152
+ if self._current is None:
153
+ return tuple(self._completed[-lookback:])
154
+ # The forming bucket occupies one slot of the requested window.
155
+ if lookback <= 1:
156
+ return (self._current,)
157
+ return (*self._completed[-(lookback - 1):], self._current)
158
+
142
159
  def latest(self) -> Bar | None:
143
160
  return self._current
144
161
 
@@ -77,6 +77,29 @@ describe a backtest result as expected future return.
77
77
  When `marginExhausted` is true, say the run hit its simulated collateral limit
78
78
  and that this is a research risk boundary, not an OKX liquidation estimate.
79
79
 
80
+ ### Offer the visual report
81
+
82
+ After reporting the numbers for a completed backtest, call `strategy_open_report`
83
+ with its `runId`. It writes a standalone HTML file and opens it in the operator's
84
+ browser: the equity curve, the drawdown shape, and where the trades landed. Those
85
+ are the parts of a result that are read as a picture rather than as figures — an
86
+ equity curve is tens of thousands of points, and paging it into the conversation
87
+ would cost a great deal and show less.
88
+
89
+ Say the file was opened, and give the path as well: a browser may not launch in
90
+ every environment, and `opened: false` means the file is written but the operator
91
+ has to open it.
92
+
93
+ For two or more runs use `strategy_open_comparison`, which overlays the curves and
94
+ lists the assumptions that differ. Repeat its `warnings` in your own reply rather
95
+ than leaving them in the document — two runs over different data, instruments,
96
+ windows, or costs are not two answers to one question, and the operator who
97
+ switches to the browser should not be the only one who learns that.
98
+
99
+ Do not open a report for a run that failed or was cancelled; report the error
100
+ instead. There is no curve to show and the file would be empty of the thing it
101
+ exists for.
102
+
80
103
  ## Judging a result
81
104
 
82
105
  A high trade count with a profit factor near or below 1 usually means fees
@@ -37,6 +37,17 @@ bars = ctx.market.bars(ctx.instrument_id, "1m", lookback=240)
37
37
  intervals are `1m`, `3m`, `5m`, `15m`, `30m`, `1H`, `2H`, `4H`, `6H`, `12H`, and
38
38
  `1D`, but a higher timeframe must be requested for the run before it is readable.
39
39
 
40
+ Always pass `lookback` when only a tail is needed. It costs the size of the window
41
+ asked for, whereas omitting it copies every bar seen so far on every call — on a
42
+ long run that is the difference between a few seconds and a few minutes.
43
+
44
+ A higher timeframe does not have to be declared on the request. The engine reads the
45
+ source and aggregates whatever interval a `bars` call names, so writing
46
+ `ctx.market.bars(id, "15m")` is enough. The run's `assumptions.intervals` reports what
47
+ was actually aggregated, which is what to quote. An interval built at runtime —
48
+ `bars(id, ctx.params["tf"])` — cannot be read from the source, so that case still needs
49
+ `intervals` on the request.
50
+
40
51
  `Bar` fields: `openTimeMs`, `closeTimeMs`, `open`, `high`, `low`, `close`,
41
52
  `volume`, `confirmed`.
42
53
 
@@ -30,9 +30,16 @@ There is no `bar` parameter on any data tool.
30
30
  | `strategy_get_run_trades` | Paged closed trades. |
31
31
  | `strategy_get_run_actions` | Paged decisions the strategy emitted. |
32
32
  | `strategy_compare_runs` | Two or more runs side by side, with the assumptions that differ and comparability warnings. |
33
+ | `strategy_open_report` | Write a standalone HTML report for one run and open it in the operator's browser. |
34
+ | `strategy_open_comparison` | The same for two or more runs, overlaying their equity curves. |
33
35
  | `strategy_cancel_run` | Cancel a queued or running run. |
34
36
  | `strategy_delete_run` | Delete a run and its stored series. Confirm with the user first. |
35
37
 
38
+ Both `open_*` tools return `{ file, opened }`. `opened: false` means the document was
39
+ written but no browser launched, so give the path either way. Pass `open: false` to
40
+ write without launching. `strategy_open_comparison` also returns `warnings`, which
41
+ belong in your reply and not only in the document.
42
+
36
43
  ## Check coverage before backtesting
37
44
 
38
45
  ```