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.
@@ -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
+ ]