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.
- package/README.en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/index.js +0 -0
- package/dist/report/fetch.d.ts +32 -0
- package/dist/report/fetch.js +42 -0
- package/dist/report/fetch.js.map +1 -1
- package/dist/strategy/schema.d.ts +7 -5
- package/dist/strategy/schema.js +7 -4
- package/dist/strategy/schema.js.map +1 -1
- package/dist/strategy/series-codec.d.ts +50 -0
- package/dist/strategy/series-codec.js +103 -0
- package/dist/strategy/series-codec.js.map +1 -0
- package/dist/strategy/service.js +4 -1
- package/dist/strategy/service.js.map +1 -1
- package/dist/strategy/store.d.ts +12 -1
- package/dist/strategy/store.js +67 -34
- package/dist/strategy/store.js.map +1 -1
- package/dist/tools/catalog.js +20 -1
- package/dist/tools/catalog.js.map +1 -1
- package/dist/tui/app.js +106 -35
- package/dist/tui/app.js.map +1 -1
- package/dist/tui/commands.d.ts +33 -0
- package/dist/tui/commands.js +59 -14
- package/dist/tui/commands.js.map +1 -1
- package/dist/tui/file-completion.d.ts +13 -0
- package/dist/tui/file-completion.js +57 -0
- package/dist/tui/file-completion.js.map +1 -0
- package/docs/strategy-research.md +27 -3
- package/package.json +1 -1
- package/python/desic_strategy/context.py +39 -14
- package/python/desic_strategy/engine.py +4 -1
- package/python/desic_strategy/live.py +1 -1
- package/python/desic_strategy/policy.py +51 -0
- package/python/desic_strategy/runner.py +36 -9
- package/python/desic_strategy/timeframe.py +17 -0
- package/skills/okx-strategy-research/SKILL.md +23 -0
- package/skills/okx-strategy-research/references/python-api.md +11 -0
- 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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
```
|