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,8 @@
1
+ """backtestchat: a local-first agentic strategy lab CLI.
2
+
3
+ Chat with a LangGraph agent to fetch keyless Binance public market data,
4
+ backtest long/flat strategies, and run simulated paper trades. Paper-only by
5
+ design: no exchange keys, no real orders.
6
+ """
7
+
8
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Enable `python -m backtestchat`."""
2
+
3
+ from backtestchat.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -0,0 +1,11 @@
1
+ """The agent layer: LangGraph graph, tools, prompt, and context compression.
2
+
3
+ This is the hand-crafted part of backtestchat. Everything below it (broker, engine,
4
+ paper, render) is boring and testable; the value here is the tool surface and
5
+ the graph wiring.
6
+ """
7
+
8
+ from backtestchat.agent.graph import MAX_TOOL_ROUNDS, build_graph
9
+ from backtestchat.agent.tools import ToolContext, build_tools
10
+
11
+ __all__ = ["MAX_TOOL_ROUNDS", "ToolContext", "build_tools", "build_graph"]
@@ -0,0 +1,222 @@
1
+ """Interactive chat runtime: builds the session and drives the streaming REPL.
2
+
3
+ Streams model tokens with rich, announces tool calls, handles the sensitive-tool
4
+ approval interrupts (y/n in the terminal), and renders the turn's widgets to
5
+ self-contained HTML afterward - opening the newest in a browser and recording it
6
+ so `backtestchat show last` can reopen it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sqlite3
12
+ import webbrowser
13
+ from dataclasses import dataclass
14
+
15
+ from langchain_core.messages import (
16
+ AIMessage,
17
+ AIMessageChunk,
18
+ HumanMessage,
19
+ ToolMessage,
20
+ )
21
+ from langgraph.checkpoint.sqlite import SqliteSaver
22
+ from langgraph.types import Command
23
+ from rich.console import Console
24
+
25
+ from backtestchat.agent.graph import ABORTED_TOOL_RESULT, build_graph
26
+ from backtestchat.agent.tools import ToolContext, build_tools
27
+ from backtestchat.artifacts import record_last_artifact
28
+ from backtestchat.broker.binance import BinanceBroker
29
+ from backtestchat.config import Config, ensure_dirs
30
+ from backtestchat.paper.store import Store
31
+ from backtestchat.render.plotly_render import render_widget
32
+
33
+
34
+ class ModelSetupError(RuntimeError):
35
+ """The configured model could not be initialized (missing package/key)."""
36
+
37
+
38
+ @dataclass
39
+ class Session:
40
+ graph: object
41
+ ctx: ToolContext
42
+ conn: sqlite3.Connection
43
+
44
+ def close(self) -> None:
45
+ self.ctx.broker.close()
46
+ self.ctx.store.close()
47
+ self.conn.close()
48
+
49
+
50
+ def init_model(model_id: str):
51
+ """init_chat_model('provider:model'), with friendly setup errors."""
52
+ from langchain.chat_models import init_chat_model
53
+
54
+ try:
55
+ return init_chat_model(model_id)
56
+ except ImportError as exc:
57
+ provider = model_id.split(":", 1)[0]
58
+ raise ModelSetupError(
59
+ f"Missing the integration package for provider '{provider}'. "
60
+ f"Install the matching extra, e.g. `uv sync --extra {provider}`. "
61
+ f"({exc})"
62
+ ) from exc
63
+ except Exception as exc: # noqa: BLE001 - surface any init failure cleanly
64
+ raise ModelSetupError(f"Could not initialize model '{model_id}': {exc}") from exc
65
+
66
+
67
+ def build_session(cfg: Config) -> Session:
68
+ ensure_dirs(cfg)
69
+ model = init_model(cfg.model)
70
+ broker = BinanceBroker(data_dir=cfg.data_dir)
71
+ store = Store(cfg.db_path)
72
+ ctx = ToolContext(broker=broker, store=store, cfg=cfg)
73
+ tools = build_tools(ctx)
74
+
75
+ conn = sqlite3.connect(str(cfg.checkpoint_path), check_same_thread=False)
76
+ saver = SqliteSaver(conn)
77
+ graph = build_graph(model, tools, saver)
78
+ return Session(graph=graph, ctx=ctx, conn=conn)
79
+
80
+
81
+ def run_repl(session: Session, thread_id: str, console: Console) -> None:
82
+ config = {"configurable": {"thread_id": thread_id}}
83
+ console.print(f"[dim]backtestchat chat (thread: {thread_id}). Paper-only "
84
+ f"simulation - not financial advice.[/dim]")
85
+ console.print("[dim]Type your message, or /exit to quit.[/dim]\n")
86
+
87
+ while True:
88
+ try:
89
+ text = console.input("[bold cyan]you[/bold cyan] > ").strip()
90
+ except (EOFError, KeyboardInterrupt):
91
+ console.print("\n[dim]bye[/dim]")
92
+ return
93
+ if not text:
94
+ continue
95
+ if text in ("/exit", "/quit", "exit", "quit"):
96
+ console.print("[dim]bye[/dim]")
97
+ return
98
+
99
+ console.print("[bold green]backtestchat[/bold green] > ", end="")
100
+ try:
101
+ _run_turn(session, text, config, console)
102
+ except Exception as exc: # noqa: BLE001 - keep the REPL alive
103
+ console.print(f"\n[red]error:[/red] {exc}")
104
+
105
+
106
+ def _run_turn(session: Session, text: str, config: dict, console: Console) -> None:
107
+ session.ctx.widgets.clear()
108
+ _reconcile_pending(session.graph, config)
109
+ _drive(session, {"messages": [HumanMessage(content=text)]}, config, console)
110
+ _render_widgets(session.ctx, console)
111
+
112
+
113
+ def _reconcile_pending(graph, config) -> None:
114
+ """If a prior turn was killed mid-flight (e.g. Ctrl-C after the model asked
115
+ for a tool but before it ran), the thread has a PENDING tools step that
116
+ would otherwise execute that stale tool on the next input. Answer any
117
+ dangling tool call as the tools node (aborted) so the stale tool never
118
+ fires and the new turn starts clean.
119
+ """
120
+ try:
121
+ snapshot = graph.get_state(config)
122
+ except Exception: # noqa: BLE001 - no/covered checkpoint yet
123
+ return
124
+ if not snapshot or not snapshot.next:
125
+ return
126
+ messages = (snapshot.values or {}).get("messages", [])
127
+ if not messages:
128
+ return
129
+ last = messages[-1]
130
+ if not (isinstance(last, AIMessage) and last.tool_calls):
131
+ return
132
+ answers = [
133
+ ToolMessage(content=ABORTED_TOOL_RESULT, tool_call_id=call["id"])
134
+ for call in last.tool_calls
135
+ ]
136
+ try:
137
+ graph.update_state(config, {"messages": answers}, as_node="tools")
138
+ except Exception: # noqa: BLE001 - fall back to the history-repair path
139
+ pass
140
+
141
+
142
+ def _drive(session, payload, config, console) -> None:
143
+ """Stream one graph run to completion, recursing on approval interrupts."""
144
+ printed = False
145
+ for mode, chunk in session.graph.stream(
146
+ payload, config, stream_mode=["updates", "messages"]
147
+ ):
148
+ if mode == "messages":
149
+ message, _meta = chunk
150
+ if isinstance(message, AIMessageChunk):
151
+ piece = _text(message.content)
152
+ if piece:
153
+ console.print(piece, end="", markup=False, highlight=False)
154
+ printed = True
155
+ elif mode == "updates":
156
+ if "__interrupt__" in chunk:
157
+ if printed:
158
+ console.print()
159
+ decision = _ask_approval(chunk["__interrupt__"], console)
160
+ _drive(session, Command(resume=decision), config, console)
161
+ return
162
+ _announce_tools(chunk, console)
163
+ if printed:
164
+ console.print()
165
+
166
+
167
+ def _ask_approval(interrupts, console: Console) -> str:
168
+ payload = interrupts[0].value or {}
169
+ summary = payload.get("summary", "Confirm this action?")
170
+ details = payload.get("details", {})
171
+ console.print(f"\n[yellow]{summary}[/yellow]")
172
+ for key, value in details.items():
173
+ console.print(f" [dim]{key}:[/dim] {value}")
174
+ try:
175
+ answer = console.input("[bold]approve? \\[y/N][/bold] ").strip()
176
+ except (EOFError, KeyboardInterrupt):
177
+ answer = "n"
178
+ return answer or "n"
179
+
180
+
181
+ def _announce_tools(update: dict, console: Console) -> None:
182
+ model_update = update.get("model")
183
+ if not model_update:
184
+ return
185
+ for message in model_update.get("messages", []):
186
+ for call in getattr(message, "tool_calls", None) or []:
187
+ console.print(f"[dim]-> {call['name']}({_fmt_args(call.get('args', {}))})[/dim]")
188
+
189
+
190
+ def _render_widgets(ctx: ToolContext, console: Console) -> None:
191
+ if not ctx.widgets:
192
+ return
193
+ last_path = None
194
+ for widget in ctx.widgets:
195
+ try:
196
+ path = render_widget(widget, ctx.cfg.artifacts_dir)
197
+ except Exception as exc: # noqa: BLE001
198
+ console.print(f"[red]chart render failed:[/red] {exc}")
199
+ continue
200
+ console.print(f"[dim]chart: {path}[/dim]")
201
+ last_path = path
202
+ if last_path is not None:
203
+ record_last_artifact(ctx.cfg, last_path)
204
+ try:
205
+ webbrowser.open(last_path.resolve().as_uri())
206
+ except Exception: # noqa: BLE001 - headless env, just skip
207
+ pass
208
+
209
+
210
+ def _text(content) -> str:
211
+ if isinstance(content, str):
212
+ return content
213
+ if isinstance(content, list):
214
+ return "".join(
215
+ part.get("text", "") if isinstance(part, dict) else str(part)
216
+ for part in content
217
+ )
218
+ return ""
219
+
220
+
221
+ def _fmt_args(args: dict) -> str:
222
+ return ", ".join(f"{k}={v!r}" for k, v in args.items())
@@ -0,0 +1,72 @@
1
+ """Tool-result compression for model context.
2
+
3
+ Tools compute rich results (full bar arrays, equity curves), but only a COMPACT
4
+ view goes into the model's context - raw series never do. The full data rides
5
+ the widget side channel to the renderer instead.
6
+
7
+ These helpers are pure and take already-computed pieces, so the tools stay thin.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from backtestchat.render.widgets import compute_series_stats
13
+
14
+
15
+ def compact_price_series(symbol, interval, range_key, points) -> dict:
16
+ """Compact stats for a fetched close series (no raw points to the model)."""
17
+ stats = compute_series_stats(points)
18
+ return {
19
+ "symbol": symbol,
20
+ "interval": interval,
21
+ "range": range_key,
22
+ "bars": stats["count"],
23
+ "start": points[0]["ts"] if points else None,
24
+ "end": points[-1]["ts"] if points else None,
25
+ "first": stats["first"],
26
+ "last": stats["last"],
27
+ "min": stats["min"],
28
+ "max": stats["max"],
29
+ "returnPct": stats["returnPct"],
30
+ "chart": "priceSeries",
31
+ }
32
+
33
+
34
+ def compact_backtest(symbol, interval, range_key, strategy_type, strategy_label,
35
+ params, result) -> dict:
36
+ """Metrics + config for the model; the equity curve and trades go to the
37
+ widget, not into context."""
38
+ return {
39
+ "symbol": symbol,
40
+ "interval": interval,
41
+ "range": range_key,
42
+ "strategyType": strategy_type,
43
+ "strategyLabel": strategy_label,
44
+ "params": params,
45
+ "metrics": result["metrics"],
46
+ "tradeCount": len(result["trades"]),
47
+ "chart": "backtestReport",
48
+ }
49
+
50
+
51
+ def compact_compare(symbol, interval, range_key, entries) -> dict:
52
+ """Ranked per-strategy metrics only (no equity curves) for the model."""
53
+ ranked = sorted(
54
+ entries,
55
+ key=lambda e: e["result"]["metrics"].get("totalReturnPct", 0.0),
56
+ reverse=True,
57
+ )
58
+ return {
59
+ "symbol": symbol,
60
+ "interval": interval,
61
+ "range": range_key,
62
+ "ranking": [
63
+ {
64
+ "strategyType": e["strategyType"],
65
+ "label": e["label"],
66
+ "params": e["params"],
67
+ "metrics": e["result"]["metrics"],
68
+ }
69
+ for e in ranked
70
+ ],
71
+ "chart": "backtestCompare",
72
+ }
@@ -0,0 +1,103 @@
1
+ """The LangGraph agent graph: model node <-> tools node, with a hard cap of 6
2
+ tool rounds per user turn.
3
+
4
+ LangGraph replaces v4's hand-rolled tool-calling loop (agent_service.py). The
5
+ graph is a standard tool-calling cycle with two refinements:
6
+
7
+ - The 6-round cap is enforced INSIDE the model node: once a turn has already
8
+ produced 6 tool-calling messages, the model is invoked WITHOUT tools, so it
9
+ must answer from what it has. This avoids ever ending a turn on an
10
+ unexecuted tool-call message (which would leave a dangling tool_call that
11
+ breaks the next turn's message pairing).
12
+ - Human-in-the-loop is a dynamic interrupt() raised inside the sensitive tools
13
+ (save_strategy, start_paper_run), not a graph-level interrupt_before - so
14
+ the pause carries a rich summary and only fires for those two tools.
15
+
16
+ The checkpointer (SqliteSaver) makes each thread resumable and is also what lets
17
+ the dynamic interrupts pause/resume.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from langchain_core.messages import (
23
+ AIMessage,
24
+ HumanMessage,
25
+ SystemMessage,
26
+ ToolMessage,
27
+ )
28
+ from langgraph.graph import END, START, MessagesState, StateGraph
29
+ from langgraph.prebuilt import ToolNode
30
+
31
+ from backtestchat.agent.prompt import build_system_prompt
32
+
33
+ MAX_TOOL_ROUNDS = 6
34
+
35
+ ABORTED_TOOL_RESULT = (
36
+ '{"error": "aborted", "message": "The previous tool call did not complete."}'
37
+ )
38
+
39
+
40
+ def repair_dangling_tool_calls(messages: list) -> list:
41
+ """Return a copy of `messages` where every assistant tool_call has a tool
42
+ response, inserting a synthetic aborted-result ToolMessage for any that do
43
+ not. Without this, a crash or hard-stop that leaves a tool_call unanswered
44
+ (in the persisted checkpoint) makes every later turn fail the provider's
45
+ "tool_calls must be followed by tool messages" rule - a wedged thread.
46
+ """
47
+ answered = {
48
+ m.tool_call_id for m in messages if isinstance(m, ToolMessage)
49
+ }
50
+ repaired: list = []
51
+ for message in messages:
52
+ repaired.append(message)
53
+ if isinstance(message, AIMessage) and message.tool_calls:
54
+ for call in message.tool_calls:
55
+ call_id = call["id"]
56
+ if call_id not in answered:
57
+ repaired.append(ToolMessage(
58
+ content=ABORTED_TOOL_RESULT, tool_call_id=call_id))
59
+ answered.add(call_id)
60
+ return repaired
61
+
62
+
63
+ def _tool_rounds_this_turn(messages) -> int:
64
+ """Count tool-calling AI messages since the last human message."""
65
+ rounds = 0
66
+ for message in reversed(messages):
67
+ if isinstance(message, HumanMessage):
68
+ break
69
+ if isinstance(message, AIMessage) and message.tool_calls:
70
+ rounds += 1
71
+ return rounds
72
+
73
+
74
+ def build_graph(model, tools, checkpointer):
75
+ """Compile the agent graph. `model` is a chat model (from init_chat_model);
76
+ `tools` are the built @tool functions; `checkpointer` is a SqliteSaver."""
77
+ system_prompt = build_system_prompt()
78
+ model_with_tools = model.bind_tools(tools)
79
+
80
+ def call_model(state: MessagesState) -> dict:
81
+ history = repair_dangling_tool_calls(list(state["messages"]))
82
+ messages = [SystemMessage(content=system_prompt)] + history
83
+ # Once the turn has hit the tool-round cap, withhold tools so the model
84
+ # is forced to answer instead of requesting more.
85
+ capped = _tool_rounds_this_turn(state["messages"]) >= MAX_TOOL_ROUNDS
86
+ llm = model if capped else model_with_tools
87
+ return {"messages": [llm.invoke(messages)]}
88
+
89
+ def should_continue(state: MessagesState):
90
+ last = state["messages"][-1]
91
+ if isinstance(last, AIMessage) and last.tool_calls:
92
+ return "tools"
93
+ return END
94
+
95
+ graph = StateGraph(MessagesState)
96
+ graph.add_node("model", call_model)
97
+ # handle_tool_errors=True: a raising tool becomes an error ToolMessage rather
98
+ # than crashing the turn and leaving a dangling tool_call in the checkpoint.
99
+ graph.add_node("tools", ToolNode(tools, handle_tool_errors=True))
100
+ graph.add_edge(START, "model")
101
+ graph.add_conditional_edges("model", should_continue, {"tools": "tools", END: END})
102
+ graph.add_edge("tools", "model")
103
+ return graph.compile(checkpointer=checkpointer)
@@ -0,0 +1,62 @@
1
+ """System prompt for the backtestchat agent.
2
+
3
+ Embeds the strategy catalog (from the registry) and the canonical encodings
4
+ directly in the system prompt so the model maps user language onto them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from backtestchat.broker.base import NAMED_RANGE_DAYS, SUPPORTED_INTERVALS
10
+ from backtestchat.strategy.registry import describe_strategies
11
+
12
+ _EXAMPLE_SYMBOLS = "BTCUSDT, ETHUSDT, SOLUSDT, XRPUSDT, DOGEUSDT"
13
+
14
+
15
+ def build_system_prompt() -> str:
16
+ intervals = ", ".join(SUPPORTED_INTERVALS)
17
+ range_keys = ", ".join(NAMED_RANGE_DAYS)
18
+ catalog = describe_strategies()
19
+
20
+ return f"""You are backtestchat, a terminal agent for designing and paper-trading
21
+ long/flat crypto strategies on Binance public market data. You are a research and
22
+ simulation tool, not a trading product and not financial advice.
23
+
24
+ Core rules:
25
+ - Paper-only. Fills are simulated from closed bars. There are no real orders and
26
+ no exchange keys. Never imply otherwise.
27
+ - State every metric and number from a tool result. NEVER invent or estimate
28
+ prices, returns, Sharpe ratios, or trade counts. If you do not have a tool
29
+ result for a number, run the tool or say you do not have it.
30
+ - Charts are generated automatically by the tools (a side channel). Do not
31
+ describe chart files or write any HTML or markup. Just discuss what the data
32
+ shows.
33
+ - Keep replies concise and plain ASCII (no fancy dashes, curly quotes, or
34
+ ellipsis characters).
35
+
36
+ Canonical encodings (map the user's words onto these; the tools accept ONLY
37
+ these exact values):
38
+ - Symbols: Binance spot pairs, e.g. {_EXAMPLE_SYMBOLS}. Uppercase, no slash.
39
+ - Intervals: {intervals}.
40
+ - Range keys: {range_keys}, or a generic Nd / Nw / Nm / Ny form (e.g. 30d, 6w,
41
+ 18m, 3y).
42
+ - Strategy params are BAR counts at the chosen interval.
43
+
44
+ Strategy catalog (strategy_type: description):
45
+ {catalog}
46
+
47
+ Paper-run semantics:
48
+ - Starting a run does not backdate: the first simulated fill is on the first bar
49
+ that CLOSES after the run is created. A brand-new run sits in a
50
+ "waiting for first tick" state until then.
51
+ - Advancing a run replays every closed bar since its last processed bar, one
52
+ signal evaluation per bar. Any command can trigger a cheap sync; there is no
53
+ daemon.
54
+ - Saving a strategy and starting a paper run both require the user to confirm in
55
+ the terminal first.
56
+
57
+ Tool-use guidance:
58
+ - If a tool returns an error with valid_options, correct your arguments and retry
59
+ using one of those options. Do not guess around a structured error.
60
+ - Prefer one tool call at a time; you have a limited number of tool rounds per
61
+ turn, so be economical.
62
+ """