backtestchat 0.1.0__py3-none-any.whl
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.
- backtestchat/__init__.py +8 -0
- backtestchat/__main__.py +6 -0
- backtestchat/agent/__init__.py +11 -0
- backtestchat/agent/chat.py +222 -0
- backtestchat/agent/context.py +72 -0
- backtestchat/agent/graph.py +103 -0
- backtestchat/agent/prompt.py +62 -0
- backtestchat/agent/tools.py +378 -0
- backtestchat/artifacts.py +57 -0
- backtestchat/broker/__init__.py +41 -0
- backtestchat/broker/base.py +247 -0
- backtestchat/broker/binance.py +290 -0
- backtestchat/cli.py +204 -0
- backtestchat/config.py +89 -0
- backtestchat/paper/__init__.py +22 -0
- backtestchat/paper/runner.py +231 -0
- backtestchat/paper/store.py +209 -0
- backtestchat/render/__init__.py +26 -0
- backtestchat/render/plotly_render.py +204 -0
- backtestchat/render/widgets.py +95 -0
- backtestchat/strategy/__init__.py +26 -0
- backtestchat/strategy/engine.py +181 -0
- backtestchat/strategy/indicators.py +186 -0
- backtestchat/strategy/registry.py +272 -0
- backtestchat/strategy/signals.py +303 -0
- backtestchat-0.1.0.dist-info/METADATA +198 -0
- backtestchat-0.1.0.dist-info/RECORD +30 -0
- backtestchat-0.1.0.dist-info/WHEEL +4 -0
- backtestchat-0.1.0.dist-info/entry_points.txt +3 -0
- backtestchat-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
"""The 10 agent tools (v1), thin wrappers over the broker / engine / store /
|
|
2
|
+
render layers.
|
|
3
|
+
|
|
4
|
+
The compress-for-model and widget-side-channel patterns are implemented as
|
|
5
|
+
LangChain @tool functions built over a ToolContext.
|
|
6
|
+
|
|
7
|
+
House rules enforced here:
|
|
8
|
+
- Tools return compact JSON for the model (see agent/context.py); raw bar and
|
|
9
|
+
equity arrays never enter context - they ride the widget side channel.
|
|
10
|
+
- Widgets are collected on ctx.widgets and rendered AFTER the model reply.
|
|
11
|
+
- Errors are loud and structured: {"error", "message", "valid_options"} so the
|
|
12
|
+
model can self-correct. No fuzzy parsing; canonical encodings only.
|
|
13
|
+
- save_strategy and start_paper_run pause for terminal confirmation via a
|
|
14
|
+
LangGraph interrupt (the human-in-the-loop showcase).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
|
|
22
|
+
from langchain_core.tools import tool
|
|
23
|
+
from langgraph.types import interrupt
|
|
24
|
+
|
|
25
|
+
from backtestchat.agent.context import (
|
|
26
|
+
compact_backtest,
|
|
27
|
+
compact_compare,
|
|
28
|
+
compact_price_series,
|
|
29
|
+
)
|
|
30
|
+
from backtestchat.broker.base import (
|
|
31
|
+
SUPPORTED_INTERVALS,
|
|
32
|
+
Broker,
|
|
33
|
+
BrokerError,
|
|
34
|
+
UnknownIntervalError,
|
|
35
|
+
UnknownRangeError,
|
|
36
|
+
UnknownSymbolError,
|
|
37
|
+
validate_interval,
|
|
38
|
+
)
|
|
39
|
+
from backtestchat.config import Config
|
|
40
|
+
from backtestchat.paper import runner
|
|
41
|
+
from backtestchat.paper.store import Store
|
|
42
|
+
from backtestchat.render.widgets import (
|
|
43
|
+
backtest_compare_widget,
|
|
44
|
+
backtest_report_widget,
|
|
45
|
+
price_series_widget,
|
|
46
|
+
)
|
|
47
|
+
from backtestchat.strategy.engine import run_backtest as engine_run_backtest
|
|
48
|
+
from backtestchat.strategy.registry import (
|
|
49
|
+
STRATEGY_REGISTRY,
|
|
50
|
+
strategy_label,
|
|
51
|
+
supported_strategies,
|
|
52
|
+
validate_params,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class ToolContext:
|
|
58
|
+
"""Shared services + the per-turn widget sink the tools write to."""
|
|
59
|
+
|
|
60
|
+
broker: Broker
|
|
61
|
+
store: Store
|
|
62
|
+
cfg: Config
|
|
63
|
+
widgets: list = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _err(code, message, valid_options=None) -> str:
|
|
67
|
+
payload = {"error": code, "message": message}
|
|
68
|
+
if valid_options is not None:
|
|
69
|
+
payload["valid_options"] = valid_options
|
|
70
|
+
return json.dumps(payload)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _map_exception(exc: Exception) -> str:
|
|
74
|
+
"""Turn a known service exception into a structured, self-repairing error."""
|
|
75
|
+
if isinstance(exc, UnknownSymbolError):
|
|
76
|
+
return _err("unknown_symbol", str(exc), exc.suggestions)
|
|
77
|
+
if isinstance(exc, UnknownIntervalError):
|
|
78
|
+
return _err("unknown_interval", str(exc), exc.valid_options)
|
|
79
|
+
if isinstance(exc, UnknownRangeError):
|
|
80
|
+
return _err("unknown_range", str(exc), exc.valid_options)
|
|
81
|
+
if isinstance(exc, BrokerError):
|
|
82
|
+
return _err("data_error", str(exc))
|
|
83
|
+
if isinstance(exc, ValueError):
|
|
84
|
+
return _err("invalid_params", str(exc))
|
|
85
|
+
raise exc
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _is_yes(decision) -> bool:
|
|
89
|
+
if isinstance(decision, bool):
|
|
90
|
+
return decision
|
|
91
|
+
return str(decision).strip().lower() in (
|
|
92
|
+
"y", "yes", "approve", "ok", "okay", "confirm", "true",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_tools(ctx: ToolContext) -> list:
|
|
97
|
+
"""Build the 10 tools bound to a ToolContext. Returns a list of tools to
|
|
98
|
+
pass to bind_tools / ToolNode."""
|
|
99
|
+
|
|
100
|
+
@tool
|
|
101
|
+
def get_quote(symbol: str) -> str:
|
|
102
|
+
"""Get the latest price and 24h change for a Binance spot symbol
|
|
103
|
+
(e.g. BTCUSDT)."""
|
|
104
|
+
try:
|
|
105
|
+
return json.dumps(ctx.broker.get_quote(symbol))
|
|
106
|
+
except (BrokerError, ValueError) as exc:
|
|
107
|
+
return _map_exception(exc)
|
|
108
|
+
|
|
109
|
+
@tool
|
|
110
|
+
def get_price_series(symbol: str, interval: str, range_key: str) -> str:
|
|
111
|
+
"""Fetch a close-price series and plot it. interval is one of
|
|
112
|
+
15m/1h/4h/1d; range_key is a named key (1m/3m/6m/1y/2y/5y) or an
|
|
113
|
+
Nd/Nw/Nm/Ny form. Returns compact stats; the chart is generated
|
|
114
|
+
separately."""
|
|
115
|
+
try:
|
|
116
|
+
symbol = ctx.broker.validate_symbol(symbol)
|
|
117
|
+
validate_interval(interval)
|
|
118
|
+
bars = ctx.broker.get_bars(symbol, interval, range_key=range_key)
|
|
119
|
+
if not bars:
|
|
120
|
+
return _err("no_data", f"No closed {interval} bars for {symbol}.")
|
|
121
|
+
points = [{"ts": b["ts"], "close": b["close"]} for b in bars]
|
|
122
|
+
ctx.widgets.append(price_series_widget(symbol, interval, range_key, points))
|
|
123
|
+
return json.dumps(compact_price_series(symbol, interval, range_key, points))
|
|
124
|
+
except (BrokerError, ValueError) as exc:
|
|
125
|
+
return _map_exception(exc)
|
|
126
|
+
|
|
127
|
+
@tool
|
|
128
|
+
def list_strategies() -> str:
|
|
129
|
+
"""List the available strategy types with their default parameters and a
|
|
130
|
+
one-line description of each."""
|
|
131
|
+
catalog = []
|
|
132
|
+
for key in supported_strategies():
|
|
133
|
+
entry = STRATEGY_REGISTRY[key]
|
|
134
|
+
catalog.append({
|
|
135
|
+
"strategyType": key,
|
|
136
|
+
"label": entry["label"],
|
|
137
|
+
"description": entry["description"],
|
|
138
|
+
"defaults": validate_params(key, {}),
|
|
139
|
+
})
|
|
140
|
+
return json.dumps({"strategies": catalog})
|
|
141
|
+
|
|
142
|
+
@tool
|
|
143
|
+
def run_backtest(symbol: str, interval: str, range_key: str,
|
|
144
|
+
strategy_type: str, params: dict | None = None) -> str:
|
|
145
|
+
"""Backtest one long/flat strategy over a symbol/interval/range and plot
|
|
146
|
+
the equity curve. params are bar counts at the chosen interval; omit to
|
|
147
|
+
use defaults. Returns metrics + trade count."""
|
|
148
|
+
try:
|
|
149
|
+
symbol = ctx.broker.validate_symbol(symbol)
|
|
150
|
+
validate_interval(interval)
|
|
151
|
+
if strategy_type not in STRATEGY_REGISTRY:
|
|
152
|
+
return _err("unknown_strategy",
|
|
153
|
+
f"Unknown strategy '{strategy_type}'.",
|
|
154
|
+
supported_strategies())
|
|
155
|
+
norm = validate_params(strategy_type, params or {})
|
|
156
|
+
bars = ctx.broker.get_bars(symbol, interval, range_key=range_key)
|
|
157
|
+
result = engine_run_backtest(bars, strategy_type, norm, interval=interval)
|
|
158
|
+
label = strategy_label(strategy_type, norm)
|
|
159
|
+
ctx.widgets.append(backtest_report_widget(
|
|
160
|
+
symbol, interval, range_key, strategy_type, label, result))
|
|
161
|
+
return json.dumps(compact_backtest(
|
|
162
|
+
symbol, interval, range_key, strategy_type, label, norm, result))
|
|
163
|
+
except (BrokerError, ValueError) as exc:
|
|
164
|
+
return _map_exception(exc)
|
|
165
|
+
|
|
166
|
+
@tool
|
|
167
|
+
def compare_strategies(symbol: str, interval: str, range_key: str,
|
|
168
|
+
strategies: list[dict]) -> str:
|
|
169
|
+
"""Backtest several strategies on the same symbol/interval/range and
|
|
170
|
+
rank them. strategies is a list of {strategy_type, params}. Returns a
|
|
171
|
+
ranked metrics table and plots the equity curves together."""
|
|
172
|
+
try:
|
|
173
|
+
symbol = ctx.broker.validate_symbol(symbol)
|
|
174
|
+
validate_interval(interval)
|
|
175
|
+
bars = ctx.broker.get_bars(symbol, interval, range_key=range_key)
|
|
176
|
+
|
|
177
|
+
entries, skipped = [], []
|
|
178
|
+
for spec in strategies:
|
|
179
|
+
stype = spec.get("strategy_type")
|
|
180
|
+
if stype not in STRATEGY_REGISTRY:
|
|
181
|
+
skipped.append({"strategyType": stype, "error": "unknown_strategy"})
|
|
182
|
+
continue
|
|
183
|
+
try:
|
|
184
|
+
norm = validate_params(stype, spec.get("params") or {})
|
|
185
|
+
result = engine_run_backtest(bars, stype, norm, interval=interval)
|
|
186
|
+
except ValueError as exc:
|
|
187
|
+
skipped.append({"strategyType": stype, "error": str(exc)})
|
|
188
|
+
continue
|
|
189
|
+
entries.append({
|
|
190
|
+
"strategyType": stype,
|
|
191
|
+
"label": strategy_label(stype, norm),
|
|
192
|
+
"params": norm,
|
|
193
|
+
"result": result,
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
if not entries:
|
|
197
|
+
return _err("no_results",
|
|
198
|
+
"None of the requested strategies could be backtested.",
|
|
199
|
+
supported_strategies())
|
|
200
|
+
|
|
201
|
+
ctx.widgets.append(backtest_compare_widget(
|
|
202
|
+
symbol, interval, range_key,
|
|
203
|
+
[{"strategyType": e["strategyType"], "label": e["label"],
|
|
204
|
+
"result": e["result"]} for e in entries]))
|
|
205
|
+
payload = compact_compare(symbol, interval, range_key, entries)
|
|
206
|
+
if skipped:
|
|
207
|
+
payload["skipped"] = skipped
|
|
208
|
+
return json.dumps(payload)
|
|
209
|
+
except (BrokerError, ValueError) as exc:
|
|
210
|
+
return _map_exception(exc)
|
|
211
|
+
|
|
212
|
+
@tool
|
|
213
|
+
def save_strategy(strategy_type: str, params: dict, symbol: str,
|
|
214
|
+
interval: str, name: str | None = None) -> str:
|
|
215
|
+
"""Save a strategy blueprint (type + params + symbol + interval) for
|
|
216
|
+
later reuse. Requires the user to confirm in the terminal first."""
|
|
217
|
+
try:
|
|
218
|
+
symbol = ctx.broker.validate_symbol(symbol)
|
|
219
|
+
validate_interval(interval)
|
|
220
|
+
if strategy_type not in STRATEGY_REGISTRY:
|
|
221
|
+
return _err("unknown_strategy",
|
|
222
|
+
f"Unknown strategy '{strategy_type}'.",
|
|
223
|
+
supported_strategies())
|
|
224
|
+
norm = validate_params(strategy_type, params or {})
|
|
225
|
+
label = strategy_label(strategy_type, norm)
|
|
226
|
+
display_name = name or f"{label} {symbol} {interval}"
|
|
227
|
+
except (BrokerError, ValueError) as exc:
|
|
228
|
+
return _map_exception(exc)
|
|
229
|
+
|
|
230
|
+
decision = interrupt({
|
|
231
|
+
"action": "save_strategy",
|
|
232
|
+
"summary": f"Save strategy '{display_name}'?",
|
|
233
|
+
"details": {"strategyType": strategy_type, "params": norm,
|
|
234
|
+
"symbol": symbol, "interval": interval},
|
|
235
|
+
})
|
|
236
|
+
if not _is_yes(decision):
|
|
237
|
+
return json.dumps({"status": "cancelled",
|
|
238
|
+
"message": "User declined to save the strategy."})
|
|
239
|
+
|
|
240
|
+
# Post-approval writes must never raise out of the tool: an exception on
|
|
241
|
+
# the interrupt-resume path escapes ToolNode's error handling and would
|
|
242
|
+
# leave a dangling tool_call in the thread checkpoint.
|
|
243
|
+
try:
|
|
244
|
+
strategy_id = ctx.store.save_strategy(
|
|
245
|
+
display_name, strategy_type, norm, symbol, interval, _now_iso())
|
|
246
|
+
except Exception as exc: # noqa: BLE001
|
|
247
|
+
return _err("store_error", f"Failed to save strategy: {exc}")
|
|
248
|
+
return json.dumps({
|
|
249
|
+
"status": "saved", "strategyId": strategy_id, "name": display_name,
|
|
250
|
+
"strategyType": strategy_type, "symbol": symbol, "interval": interval,
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
@tool
|
|
254
|
+
def list_saved_strategies() -> str:
|
|
255
|
+
"""List strategies the user has saved."""
|
|
256
|
+
rows = [
|
|
257
|
+
{"id": s["id"], "name": s["name"], "strategyType": s["strategy_type"],
|
|
258
|
+
"params": s["params"], "symbol": s["symbol"], "interval": s["interval"]}
|
|
259
|
+
for s in ctx.store.list_strategies()
|
|
260
|
+
]
|
|
261
|
+
return json.dumps({"strategies": rows})
|
|
262
|
+
|
|
263
|
+
@tool
|
|
264
|
+
def start_paper_run(strategy_id: int | None = None,
|
|
265
|
+
strategy_type: str | None = None,
|
|
266
|
+
params: dict | None = None, symbol: str | None = None,
|
|
267
|
+
interval: str | None = None,
|
|
268
|
+
cash: float = runner.DEFAULT_STARTING_CASH) -> str:
|
|
269
|
+
"""Start a paper (simulated) run from a saved strategy id OR an inline
|
|
270
|
+
spec (strategy_type + symbol + interval + optional params). Paper-only:
|
|
271
|
+
no real orders. Requires the user to confirm in the terminal first."""
|
|
272
|
+
try:
|
|
273
|
+
if strategy_id is not None:
|
|
274
|
+
saved = ctx.store.get_strategy(strategy_id)
|
|
275
|
+
if saved is None:
|
|
276
|
+
return _err("unknown_strategy_id",
|
|
277
|
+
f"No saved strategy with id {strategy_id}.")
|
|
278
|
+
strategy_type = saved["strategy_type"]
|
|
279
|
+
params = saved["params"]
|
|
280
|
+
symbol = saved["symbol"]
|
|
281
|
+
interval = saved["interval"]
|
|
282
|
+
if not (strategy_type and symbol and interval):
|
|
283
|
+
return _err("missing_spec",
|
|
284
|
+
"Provide a strategy_id, or all of strategy_type, "
|
|
285
|
+
"symbol, and interval.")
|
|
286
|
+
symbol = ctx.broker.validate_symbol(symbol)
|
|
287
|
+
validate_interval(interval)
|
|
288
|
+
if strategy_type not in STRATEGY_REGISTRY:
|
|
289
|
+
return _err("unknown_strategy",
|
|
290
|
+
f"Unknown strategy '{strategy_type}'.",
|
|
291
|
+
supported_strategies())
|
|
292
|
+
norm = validate_params(strategy_type, params or {})
|
|
293
|
+
label = strategy_label(strategy_type, norm)
|
|
294
|
+
except (BrokerError, ValueError) as exc:
|
|
295
|
+
return _map_exception(exc)
|
|
296
|
+
|
|
297
|
+
decision = interrupt({
|
|
298
|
+
"action": "start_paper_run",
|
|
299
|
+
"summary": f"Start a paper run of {label} on {symbol} {interval} "
|
|
300
|
+
f"with {cash:.0f} USDT?",
|
|
301
|
+
"details": {"strategyType": strategy_type, "params": norm,
|
|
302
|
+
"symbol": symbol, "interval": interval, "cash": cash},
|
|
303
|
+
})
|
|
304
|
+
if not _is_yes(decision):
|
|
305
|
+
return json.dumps({"status": "cancelled",
|
|
306
|
+
"message": "User declined to start the paper run."})
|
|
307
|
+
|
|
308
|
+
# As with save_strategy, never raise past this point (interrupt-resume
|
|
309
|
+
# path) so the thread checkpoint cannot be poisoned by a dangling call.
|
|
310
|
+
try:
|
|
311
|
+
result = runner.create_run(
|
|
312
|
+
ctx.store, ctx.broker, symbol=symbol, interval=interval,
|
|
313
|
+
strategy_type=strategy_type, params=norm, cash=cash,
|
|
314
|
+
strategy_id=strategy_id)
|
|
315
|
+
except (BrokerError, ValueError) as exc:
|
|
316
|
+
return _map_exception(exc)
|
|
317
|
+
except Exception as exc: # noqa: BLE001
|
|
318
|
+
return _err("run_error", f"Failed to start paper run: {exc}")
|
|
319
|
+
|
|
320
|
+
return json.dumps({
|
|
321
|
+
"status": "started", "runId": result["run_id"],
|
|
322
|
+
"runStatus": result["status"], "symbol": symbol, "interval": interval,
|
|
323
|
+
"strategyType": strategy_type, "strategyLabel": label,
|
|
324
|
+
"startingCash": cash,
|
|
325
|
+
"note": "No backdated fills: the first simulated fill is on the "
|
|
326
|
+
"first bar that closes after now. Sync later to advance it.",
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
@tool
|
|
330
|
+
def list_paper_runs(status: str | None = None) -> str:
|
|
331
|
+
"""List paper runs (optionally filtered by status: active/stopped).
|
|
332
|
+
Triggers a cheap sync first so equity and position are current."""
|
|
333
|
+
try:
|
|
334
|
+
runner.sync_all(ctx.store, ctx.broker)
|
|
335
|
+
except BrokerError:
|
|
336
|
+
pass # list stored state even if a sync fetch fails
|
|
337
|
+
rows = [
|
|
338
|
+
{"id": r["id"], "symbol": r["symbol"], "interval": r["interval"],
|
|
339
|
+
"strategyType": r["strategy_type"], "status": r["status"],
|
|
340
|
+
"equity": round(r["equity"], 2), "cash": round(r["cash"], 2),
|
|
341
|
+
"positionQty": r["position_qty"],
|
|
342
|
+
"realizedPnl": round(r["realized_pnl"], 2),
|
|
343
|
+
"lastBarTs": r["last_bar_ts"], "note": r["note"]}
|
|
344
|
+
for r in ctx.store.list_runs(status)
|
|
345
|
+
]
|
|
346
|
+
return json.dumps({"runs": rows})
|
|
347
|
+
|
|
348
|
+
@tool
|
|
349
|
+
def stop_paper_run(run_id: int) -> str:
|
|
350
|
+
"""Sync a paper run one last time, then stop it. Returns a final
|
|
351
|
+
summary (equity, realized PnL, trade count)."""
|
|
352
|
+
try:
|
|
353
|
+
summary = runner.stop_run(ctx.store, ctx.broker, run_id)
|
|
354
|
+
except (BrokerError, ValueError) as exc:
|
|
355
|
+
return _map_exception(exc)
|
|
356
|
+
if summary.get("status") == "not_found":
|
|
357
|
+
return _err("unknown_run_id", f"No paper run with id {run_id}.")
|
|
358
|
+
return json.dumps({
|
|
359
|
+
"status": "stopped", "runId": run_id,
|
|
360
|
+
"equity": round(summary.get("equity", 0.0), 2),
|
|
361
|
+
"realizedPnl": round(summary.get("realized_pnl", 0.0), 2),
|
|
362
|
+
"tradeCount": summary.get("trade_count", 0),
|
|
363
|
+
"symbol": summary.get("symbol"), "interval": summary.get("interval"),
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
return [
|
|
367
|
+
get_quote, get_price_series, list_strategies, run_backtest,
|
|
368
|
+
compare_strategies, save_strategy, list_saved_strategies,
|
|
369
|
+
start_paper_run, list_paper_runs, stop_paper_run,
|
|
370
|
+
]
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _now_iso() -> str:
|
|
374
|
+
import time
|
|
375
|
+
|
|
376
|
+
from backtestchat.broker.base import ms_to_iso
|
|
377
|
+
|
|
378
|
+
return ms_to_iso(int(time.time() * 1000))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Rendered-artifact bookkeeping: the "last artifact" pointer and resolution.
|
|
2
|
+
|
|
3
|
+
Kept dependency-light (stdlib only) so the CLI `show` command can use it without
|
|
4
|
+
importing the chat/agent stack.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from backtestchat.config import Config
|
|
12
|
+
|
|
13
|
+
LAST_ARTIFACT_FILE = "last_artifact.txt"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def record_last_artifact(cfg: Config, path: Path) -> None:
|
|
17
|
+
"""Remember the most recently rendered artifact (for `show last`)."""
|
|
18
|
+
try:
|
|
19
|
+
(cfg.data_dir / LAST_ARTIFACT_FILE).write_text(str(path))
|
|
20
|
+
except OSError:
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def last_artifact(cfg: Config) -> Path | None:
|
|
25
|
+
"""The artifact recorded by the last render, if it still exists."""
|
|
26
|
+
pointer = cfg.data_dir / LAST_ARTIFACT_FILE
|
|
27
|
+
if not pointer.is_file():
|
|
28
|
+
return None
|
|
29
|
+
path = Path(pointer.read_text().strip())
|
|
30
|
+
return path if path.is_file() else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def newest_artifact(cfg: Config) -> Path | None:
|
|
34
|
+
"""The most recently modified HTML file in the artifacts dir."""
|
|
35
|
+
htmls = sorted(cfg.artifacts_dir.glob("*.html"), key=lambda p: p.stat().st_mtime)
|
|
36
|
+
return htmls[-1] if htmls else None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def resolve_artifact(cfg: Config, target: str) -> Path | None:
|
|
40
|
+
"""Resolve a `show` target to an artifact path.
|
|
41
|
+
|
|
42
|
+
'last'/'latest' -> the recorded pointer, else the newest HTML. Otherwise a
|
|
43
|
+
direct path, a filename under the artifacts dir, or a substring match.
|
|
44
|
+
"""
|
|
45
|
+
if target in ("last", "latest"):
|
|
46
|
+
return last_artifact(cfg) or newest_artifact(cfg)
|
|
47
|
+
|
|
48
|
+
direct = Path(target)
|
|
49
|
+
if direct.is_file():
|
|
50
|
+
return direct
|
|
51
|
+
|
|
52
|
+
named = cfg.artifacts_dir / target
|
|
53
|
+
if named.is_file():
|
|
54
|
+
return named
|
|
55
|
+
|
|
56
|
+
matches = sorted(cfg.artifacts_dir.glob(f"*{target}*.html"))
|
|
57
|
+
return matches[-1] if matches else None
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Market-data broker layer.
|
|
2
|
+
|
|
3
|
+
`base` holds the Broker interface plus the pure frequency/window logic and the
|
|
4
|
+
structured broker errors. `binance` is the keyless public-REST implementation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from backtestchat.broker.base import (
|
|
8
|
+
BARS_PER_YEAR,
|
|
9
|
+
INTERVAL_MS,
|
|
10
|
+
SUPPORTED_INTERVALS,
|
|
11
|
+
Broker,
|
|
12
|
+
BrokerError,
|
|
13
|
+
UnknownIntervalError,
|
|
14
|
+
UnknownRangeError,
|
|
15
|
+
UnknownSymbolError,
|
|
16
|
+
bars_per_year,
|
|
17
|
+
drop_forming_klines,
|
|
18
|
+
iso_to_ms,
|
|
19
|
+
kline_to_bar,
|
|
20
|
+
ms_to_iso,
|
|
21
|
+
resolve_range_days,
|
|
22
|
+
validate_interval,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"BARS_PER_YEAR",
|
|
27
|
+
"INTERVAL_MS",
|
|
28
|
+
"SUPPORTED_INTERVALS",
|
|
29
|
+
"Broker",
|
|
30
|
+
"BrokerError",
|
|
31
|
+
"UnknownIntervalError",
|
|
32
|
+
"UnknownRangeError",
|
|
33
|
+
"UnknownSymbolError",
|
|
34
|
+
"bars_per_year",
|
|
35
|
+
"drop_forming_klines",
|
|
36
|
+
"iso_to_ms",
|
|
37
|
+
"kline_to_bar",
|
|
38
|
+
"ms_to_iso",
|
|
39
|
+
"resolve_range_days",
|
|
40
|
+
"validate_interval",
|
|
41
|
+
]
|