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
backtestchat/__init__.py
ADDED
|
@@ -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"
|
backtestchat/__main__.py
ADDED
|
@@ -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
|
+
"""
|