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,209 @@
|
|
|
1
|
+
"""Thin hand-rolled sqlite store for strategies, paper runs, and trades.
|
|
2
|
+
|
|
3
|
+
No ORM, no migration framework: CREATE TABLE IF NOT EXISTS on construction
|
|
4
|
+
(single user, so no user_id; money columns are REAL floats; only trades are
|
|
5
|
+
persisted, no per-bar ticks table). LangGraph checkpoints live in a separate
|
|
6
|
+
sqlite file managed by SqliteSaver; this store never touches them.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sqlite3
|
|
13
|
+
import threading
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
_SCHEMA = """
|
|
17
|
+
CREATE TABLE IF NOT EXISTS strategies (
|
|
18
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
19
|
+
name TEXT NOT NULL,
|
|
20
|
+
strategy_type TEXT NOT NULL,
|
|
21
|
+
params_json TEXT NOT NULL,
|
|
22
|
+
symbol TEXT NOT NULL,
|
|
23
|
+
interval TEXT NOT NULL,
|
|
24
|
+
created_at TEXT NOT NULL
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS paper_runs (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
29
|
+
strategy_id INTEGER,
|
|
30
|
+
strategy_type TEXT NOT NULL,
|
|
31
|
+
params_json TEXT NOT NULL,
|
|
32
|
+
symbol TEXT NOT NULL,
|
|
33
|
+
interval TEXT NOT NULL,
|
|
34
|
+
cash_start REAL NOT NULL,
|
|
35
|
+
cash REAL NOT NULL,
|
|
36
|
+
position_qty REAL NOT NULL DEFAULT 0,
|
|
37
|
+
position_avg_price REAL,
|
|
38
|
+
position_entry_ts TEXT,
|
|
39
|
+
last_price REAL,
|
|
40
|
+
equity REAL NOT NULL,
|
|
41
|
+
realized_pnl REAL NOT NULL DEFAULT 0,
|
|
42
|
+
note TEXT NOT NULL DEFAULT '',
|
|
43
|
+
status TEXT NOT NULL DEFAULT 'active',
|
|
44
|
+
created_at TEXT NOT NULL,
|
|
45
|
+
last_tick_at TEXT,
|
|
46
|
+
last_bar_ts TEXT
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS paper_trades (
|
|
50
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
run_id INTEGER NOT NULL,
|
|
52
|
+
side TEXT NOT NULL,
|
|
53
|
+
ts TEXT NOT NULL,
|
|
54
|
+
price REAL NOT NULL,
|
|
55
|
+
qty REAL NOT NULL,
|
|
56
|
+
equity_after REAL NOT NULL
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
CREATE INDEX IF NOT EXISTS idx_paper_runs_status ON paper_runs(status);
|
|
60
|
+
CREATE INDEX IF NOT EXISTS idx_paper_trades_run ON paper_trades(run_id);
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
# Columns update_run may set (guards against arbitrary SQL injection via keys).
|
|
64
|
+
_RUN_UPDATABLE = {
|
|
65
|
+
"cash", "position_qty", "position_avg_price", "position_entry_ts",
|
|
66
|
+
"last_price", "equity", "realized_pnl", "note", "status",
|
|
67
|
+
"last_tick_at", "last_bar_ts",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Store:
|
|
72
|
+
"""Single-file sqlite store. One instance per process is expected."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, db_path: str | Path):
|
|
75
|
+
self._path = str(db_path)
|
|
76
|
+
if self._path != ":memory:":
|
|
77
|
+
Path(self._path).parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
# check_same_thread=False: LangGraph executes tools in worker threads,
|
|
79
|
+
# so the connection is used off the creating thread. A lock serializes
|
|
80
|
+
# access (single process, effectively one tool at a time).
|
|
81
|
+
self._conn = sqlite3.connect(self._path, check_same_thread=False)
|
|
82
|
+
self._conn.row_factory = sqlite3.Row
|
|
83
|
+
self._lock = threading.RLock()
|
|
84
|
+
with self._lock:
|
|
85
|
+
self._conn.executescript(_SCHEMA)
|
|
86
|
+
self._conn.commit()
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
with self._lock:
|
|
90
|
+
self._conn.close()
|
|
91
|
+
|
|
92
|
+
def __enter__(self) -> "Store":
|
|
93
|
+
return self
|
|
94
|
+
|
|
95
|
+
def __exit__(self, *exc) -> None:
|
|
96
|
+
self.close()
|
|
97
|
+
|
|
98
|
+
# -- strategies --------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def save_strategy(self, name, strategy_type, params, symbol, interval, created_at) -> int:
|
|
101
|
+
with self._lock:
|
|
102
|
+
cur = self._conn.execute(
|
|
103
|
+
"INSERT INTO strategies (name, strategy_type, params_json, symbol, "
|
|
104
|
+
"interval, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
105
|
+
(name, strategy_type, json.dumps(params), symbol, interval, created_at),
|
|
106
|
+
)
|
|
107
|
+
self._conn.commit()
|
|
108
|
+
return cur.lastrowid
|
|
109
|
+
|
|
110
|
+
def get_strategy(self, strategy_id) -> dict | None:
|
|
111
|
+
with self._lock:
|
|
112
|
+
row = self._conn.execute(
|
|
113
|
+
"SELECT * FROM strategies WHERE id = ?", (strategy_id,)
|
|
114
|
+
).fetchone()
|
|
115
|
+
return self._strategy_row(row)
|
|
116
|
+
|
|
117
|
+
def list_strategies(self) -> list[dict]:
|
|
118
|
+
with self._lock:
|
|
119
|
+
rows = self._conn.execute(
|
|
120
|
+
"SELECT * FROM strategies ORDER BY id DESC"
|
|
121
|
+
).fetchall()
|
|
122
|
+
return [self._strategy_row(r) for r in rows]
|
|
123
|
+
|
|
124
|
+
# -- runs --------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def create_run(self, *, strategy_id, strategy_type, params, symbol, interval,
|
|
127
|
+
cash_start, created_at) -> int:
|
|
128
|
+
with self._lock:
|
|
129
|
+
cur = self._conn.execute(
|
|
130
|
+
"INSERT INTO paper_runs (strategy_id, strategy_type, params_json, "
|
|
131
|
+
"symbol, interval, cash_start, cash, position_qty, equity, "
|
|
132
|
+
"realized_pnl, status, created_at) "
|
|
133
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, 0, 'active', ?)",
|
|
134
|
+
(strategy_id, strategy_type, json.dumps(params), symbol, interval,
|
|
135
|
+
cash_start, cash_start, cash_start, created_at),
|
|
136
|
+
)
|
|
137
|
+
self._conn.commit()
|
|
138
|
+
return cur.lastrowid
|
|
139
|
+
|
|
140
|
+
def get_run(self, run_id) -> dict | None:
|
|
141
|
+
with self._lock:
|
|
142
|
+
row = self._conn.execute(
|
|
143
|
+
"SELECT * FROM paper_runs WHERE id = ?", (run_id,)
|
|
144
|
+
).fetchone()
|
|
145
|
+
return self._run_row(row)
|
|
146
|
+
|
|
147
|
+
def list_runs(self, status: str | None = None) -> list[dict]:
|
|
148
|
+
with self._lock:
|
|
149
|
+
if status:
|
|
150
|
+
rows = self._conn.execute(
|
|
151
|
+
"SELECT * FROM paper_runs WHERE status = ? ORDER BY id DESC",
|
|
152
|
+
(status,),
|
|
153
|
+
).fetchall()
|
|
154
|
+
else:
|
|
155
|
+
rows = self._conn.execute(
|
|
156
|
+
"SELECT * FROM paper_runs ORDER BY id DESC"
|
|
157
|
+
).fetchall()
|
|
158
|
+
return [self._run_row(r) for r in rows]
|
|
159
|
+
|
|
160
|
+
def update_run(self, run_id, **fields) -> None:
|
|
161
|
+
if not fields:
|
|
162
|
+
return
|
|
163
|
+
bad = set(fields) - _RUN_UPDATABLE
|
|
164
|
+
if bad:
|
|
165
|
+
raise ValueError(f"Cannot update paper_runs columns: {sorted(bad)}")
|
|
166
|
+
assignments = ", ".join(f"{col} = ?" for col in fields)
|
|
167
|
+
values = list(fields.values()) + [run_id]
|
|
168
|
+
with self._lock:
|
|
169
|
+
self._conn.execute(
|
|
170
|
+
f"UPDATE paper_runs SET {assignments} WHERE id = ?", values
|
|
171
|
+
)
|
|
172
|
+
self._conn.commit()
|
|
173
|
+
|
|
174
|
+
# -- trades ------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def append_trade(self, run_id, side, ts, price, qty, equity_after) -> int:
|
|
177
|
+
with self._lock:
|
|
178
|
+
cur = self._conn.execute(
|
|
179
|
+
"INSERT INTO paper_trades (run_id, side, ts, price, qty, equity_after) "
|
|
180
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
181
|
+
(run_id, side, ts, price, qty, equity_after),
|
|
182
|
+
)
|
|
183
|
+
self._conn.commit()
|
|
184
|
+
return cur.lastrowid
|
|
185
|
+
|
|
186
|
+
def list_trades(self, run_id) -> list[dict]:
|
|
187
|
+
with self._lock:
|
|
188
|
+
rows = self._conn.execute(
|
|
189
|
+
"SELECT * FROM paper_trades WHERE run_id = ? ORDER BY id", (run_id,)
|
|
190
|
+
).fetchall()
|
|
191
|
+
return [dict(r) for r in rows]
|
|
192
|
+
|
|
193
|
+
# -- row helpers -------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
@staticmethod
|
|
196
|
+
def _strategy_row(row) -> dict | None:
|
|
197
|
+
if row is None:
|
|
198
|
+
return None
|
|
199
|
+
data = dict(row)
|
|
200
|
+
data["params"] = json.loads(data.pop("params_json"))
|
|
201
|
+
return data
|
|
202
|
+
|
|
203
|
+
@staticmethod
|
|
204
|
+
def _run_row(row) -> dict | None:
|
|
205
|
+
if row is None:
|
|
206
|
+
return None
|
|
207
|
+
data = dict(row)
|
|
208
|
+
data["params"] = json.loads(data.pop("params_json"))
|
|
209
|
+
return data
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Render layer: widget dict builders (the contract) and the deterministic
|
|
2
|
+
plotly renderer. Data tools build widgets; this layer draws them. The LLM never
|
|
3
|
+
writes markup.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from backtestchat.render.plotly_render import (
|
|
7
|
+
RenderError,
|
|
8
|
+
figure_for_widget,
|
|
9
|
+
render_widget,
|
|
10
|
+
)
|
|
11
|
+
from backtestchat.render.widgets import (
|
|
12
|
+
backtest_compare_widget,
|
|
13
|
+
backtest_report_widget,
|
|
14
|
+
compute_series_stats,
|
|
15
|
+
price_series_widget,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"RenderError",
|
|
20
|
+
"backtest_compare_widget",
|
|
21
|
+
"backtest_report_widget",
|
|
22
|
+
"compute_series_stats",
|
|
23
|
+
"figure_for_widget",
|
|
24
|
+
"price_series_widget",
|
|
25
|
+
"render_widget",
|
|
26
|
+
]
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""Deterministic widget -> plotly figure -> self-contained HTML file.
|
|
2
|
+
|
|
3
|
+
Rendering is a pure function of the widget dict (house rule): the same widget
|
|
4
|
+
always yields the same figure. HTML files embed plotly.js inline
|
|
5
|
+
(include_plotlyjs=True) so an artifact opens with no network and no external
|
|
6
|
+
assets. The LLM never touches this module.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import plotly.graph_objects as go
|
|
15
|
+
from plotly.subplots import make_subplots
|
|
16
|
+
|
|
17
|
+
# A muted, consistent palette (dark-neutral, readable in a browser).
|
|
18
|
+
_EQUITY_COLOR = "#2563eb" # blue
|
|
19
|
+
_PRICE_COLOR = "#0f766e" # teal
|
|
20
|
+
_BUY_COLOR = "#16a34a" # green
|
|
21
|
+
_SELL_COLOR = "#dc2626" # red
|
|
22
|
+
_COMPARE_COLORS = ["#2563eb", "#dc2626", "#16a34a", "#d97706", "#7c3aed",
|
|
23
|
+
"#0891b2", "#db2777", "#65a30d"]
|
|
24
|
+
|
|
25
|
+
_METRIC_COLUMNS = [
|
|
26
|
+
("label", "Strategy"),
|
|
27
|
+
("totalReturnPct", "Total %"),
|
|
28
|
+
("cagrPct", "CAGR %"),
|
|
29
|
+
("sharpe", "Sharpe"),
|
|
30
|
+
("maxDrawdownPct", "MaxDD %"),
|
|
31
|
+
("winRate", "Win %"),
|
|
32
|
+
("numTrades", "Trades"),
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RenderError(ValueError):
|
|
37
|
+
"""Unknown or malformed widget."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Public API
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def render_widget(widget: dict, artifacts_dir, filename: str | None = None,
|
|
46
|
+
open_browser: bool = False) -> Path:
|
|
47
|
+
"""Render a widget to a self-contained HTML file under artifacts_dir.
|
|
48
|
+
|
|
49
|
+
Returns the file Path. Pass an explicit filename for deterministic output
|
|
50
|
+
(tests); otherwise a unique name is derived from the widget + a timestamp.
|
|
51
|
+
"""
|
|
52
|
+
fig = figure_for_widget(widget)
|
|
53
|
+
|
|
54
|
+
artifacts_dir = Path(artifacts_dir)
|
|
55
|
+
artifacts_dir.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
path = artifacts_dir / (filename or _default_filename(widget))
|
|
57
|
+
|
|
58
|
+
fig.write_html(str(path), include_plotlyjs=True, full_html=True)
|
|
59
|
+
|
|
60
|
+
if open_browser:
|
|
61
|
+
import webbrowser
|
|
62
|
+
|
|
63
|
+
webbrowser.open(path.as_uri())
|
|
64
|
+
|
|
65
|
+
return path
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def figure_for_widget(widget: dict) -> go.Figure:
|
|
69
|
+
builder = {
|
|
70
|
+
"priceSeries": _fig_price_series,
|
|
71
|
+
"backtestReport": _fig_backtest_report,
|
|
72
|
+
"backtestCompare": _fig_backtest_compare,
|
|
73
|
+
}.get(widget.get("type"))
|
|
74
|
+
if builder is None:
|
|
75
|
+
raise RenderError(f"Unknown widget type: {widget.get('type')!r}")
|
|
76
|
+
return builder(widget)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Figures
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _fig_price_series(widget: dict) -> go.Figure:
|
|
85
|
+
points = widget["points"]
|
|
86
|
+
xs = [p["ts"] for p in points]
|
|
87
|
+
ys = [p["close"] for p in points]
|
|
88
|
+
|
|
89
|
+
fig = go.Figure()
|
|
90
|
+
fig.add_trace(go.Scatter(
|
|
91
|
+
x=xs, y=ys, mode="lines", name="close",
|
|
92
|
+
line=dict(color=_PRICE_COLOR, width=1.6),
|
|
93
|
+
))
|
|
94
|
+
stats = widget.get("stats", {})
|
|
95
|
+
subtitle = ""
|
|
96
|
+
if stats.get("returnPct") is not None:
|
|
97
|
+
subtitle = (f" (last {stats['last']}, {stats['returnPct']:+}% over "
|
|
98
|
+
f"{stats['count']} bars)")
|
|
99
|
+
fig.update_layout(
|
|
100
|
+
title=f"{widget['symbol']} {widget['interval']} - {widget['range']}{subtitle}",
|
|
101
|
+
xaxis_title="time", yaxis_title="price",
|
|
102
|
+
template="plotly_white", hovermode="x unified",
|
|
103
|
+
)
|
|
104
|
+
return fig
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _fig_backtest_report(widget: dict) -> go.Figure:
|
|
108
|
+
curve = widget["equityCurve"]
|
|
109
|
+
xs = [p["ts"] for p in curve]
|
|
110
|
+
ys = [p["equity"] for p in curve]
|
|
111
|
+
equity_at = {p["ts"]: p["equity"] for p in curve}
|
|
112
|
+
|
|
113
|
+
fig = go.Figure()
|
|
114
|
+
fig.add_trace(go.Scatter(
|
|
115
|
+
x=xs, y=ys, mode="lines", name="equity",
|
|
116
|
+
line=dict(color=_EQUITY_COLOR, width=1.8),
|
|
117
|
+
))
|
|
118
|
+
|
|
119
|
+
_add_trade_markers(fig, widget["trades"], equity_at)
|
|
120
|
+
|
|
121
|
+
m = widget["metrics"]
|
|
122
|
+
subtitle = (f"total {m['totalReturnPct']:+}% | Sharpe {m['sharpe']} | "
|
|
123
|
+
f"maxDD {m['maxDrawdownPct']}% | {m['numTrades']} trades")
|
|
124
|
+
fig.update_layout(
|
|
125
|
+
title=f"{widget['strategyLabel']} on {widget['symbol']} "
|
|
126
|
+
f"{widget['interval']} ({widget['range']})<br>"
|
|
127
|
+
f"<sub>{subtitle}</sub>",
|
|
128
|
+
xaxis_title="time", yaxis_title="equity (USDT)",
|
|
129
|
+
template="plotly_white", hovermode="x unified",
|
|
130
|
+
)
|
|
131
|
+
return fig
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _add_trade_markers(fig: go.Figure, trades: list[dict], equity_at: dict) -> None:
|
|
135
|
+
for side, color, symbol in (("buy", _BUY_COLOR, "triangle-up"),
|
|
136
|
+
("sell", _SELL_COLOR, "triangle-down")):
|
|
137
|
+
side_trades = [t for t in trades if t["side"] == side]
|
|
138
|
+
xs = [t["ts"] for t in side_trades if t["ts"] in equity_at]
|
|
139
|
+
ys = [equity_at[t["ts"]] for t in side_trades if t["ts"] in equity_at]
|
|
140
|
+
texts = [f"{side} @ {t['price']}" for t in side_trades if t["ts"] in equity_at]
|
|
141
|
+
if not xs:
|
|
142
|
+
continue
|
|
143
|
+
fig.add_trace(go.Scatter(
|
|
144
|
+
x=xs, y=ys, mode="markers", name=side, text=texts,
|
|
145
|
+
hovertemplate="%{text}<extra></extra>",
|
|
146
|
+
marker=dict(color=color, size=10, symbol=symbol,
|
|
147
|
+
line=dict(color="white", width=1)),
|
|
148
|
+
))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _fig_backtest_compare(widget: dict) -> go.Figure:
|
|
152
|
+
entries = widget["entries"]
|
|
153
|
+
|
|
154
|
+
fig = make_subplots(
|
|
155
|
+
rows=2, cols=1, row_heights=[0.68, 0.32], vertical_spacing=0.09,
|
|
156
|
+
specs=[[{"type": "xy"}], [{"type": "table"}]],
|
|
157
|
+
subplot_titles=("equity curves", "ranked metrics"),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
for i, entry in enumerate(entries):
|
|
161
|
+
curve = entry["equityCurve"]
|
|
162
|
+
fig.add_trace(go.Scatter(
|
|
163
|
+
x=[p["ts"] for p in curve], y=[p["equity"] for p in curve],
|
|
164
|
+
mode="lines", name=entry["label"],
|
|
165
|
+
line=dict(color=_COMPARE_COLORS[i % len(_COMPARE_COLORS)], width=1.6),
|
|
166
|
+
), row=1, col=1)
|
|
167
|
+
|
|
168
|
+
header = [label for _, label in _METRIC_COLUMNS]
|
|
169
|
+
columns = []
|
|
170
|
+
for key, _ in _METRIC_COLUMNS:
|
|
171
|
+
if key == "label":
|
|
172
|
+
columns.append([e["label"] for e in entries])
|
|
173
|
+
else:
|
|
174
|
+
columns.append([e["metrics"].get(key) for e in entries])
|
|
175
|
+
|
|
176
|
+
fig.add_trace(go.Table(
|
|
177
|
+
header=dict(values=header, fill_color="#1e293b",
|
|
178
|
+
font=dict(color="white"), align="left"),
|
|
179
|
+
cells=dict(values=columns, align="left",
|
|
180
|
+
fill_color="#f8fafc"),
|
|
181
|
+
), row=2, col=1)
|
|
182
|
+
|
|
183
|
+
fig.update_layout(
|
|
184
|
+
title=f"Strategy comparison - {widget['symbol']} "
|
|
185
|
+
f"{widget['interval']} ({widget['range']})",
|
|
186
|
+
template="plotly_white", hovermode="x unified",
|
|
187
|
+
)
|
|
188
|
+
fig.update_yaxes(title_text="equity (USDT)", row=1, col=1)
|
|
189
|
+
return fig
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# Filenames
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _default_filename(widget: dict) -> str:
|
|
198
|
+
parts = [widget.get("type", "widget")]
|
|
199
|
+
if widget.get("symbol"):
|
|
200
|
+
parts.append(widget["symbol"])
|
|
201
|
+
if widget.get("interval"):
|
|
202
|
+
parts.append(widget["interval"])
|
|
203
|
+
parts.append(str(time.time_ns()))
|
|
204
|
+
return "-".join(parts) + ".html"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Widget dict builders - the render contract.
|
|
2
|
+
|
|
3
|
+
A widget is a plain JSON-able dict describing a chart. Data tools build widgets
|
|
4
|
+
from tool results; the renderer (plotly_render.py) turns a widget into a
|
|
5
|
+
self-contained HTML file. This is the ONLY thing the renderer understands, and
|
|
6
|
+
the LLM never produces one directly - it just triggers a tool, and the tool
|
|
7
|
+
builds the widget (house rule: data tools do not render; the LLM writes no
|
|
8
|
+
markup).
|
|
9
|
+
|
|
10
|
+
Widget shapes (the contract):
|
|
11
|
+
|
|
12
|
+
priceSeries
|
|
13
|
+
{type, symbol, interval, range, points:[{ts, close}], stats:{...}}
|
|
14
|
+
|
|
15
|
+
backtestReport
|
|
16
|
+
{type, symbol, interval, range, strategyType, strategyLabel,
|
|
17
|
+
equityCurve:[{ts, equity}], trades:[{ts, side, price}], metrics:{...}}
|
|
18
|
+
|
|
19
|
+
backtestCompare
|
|
20
|
+
{type, symbol, interval, range,
|
|
21
|
+
entries:[{strategyType, label, metrics:{...}, equityCurve:[{ts, equity}]}]}
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def compute_series_stats(points: list[dict]) -> dict:
|
|
28
|
+
"""Compact stats for a close-price series (also the payload the
|
|
29
|
+
get_price_series tool returns to the model - no raw bars in context)."""
|
|
30
|
+
closes = [p["close"] for p in points]
|
|
31
|
+
if not closes:
|
|
32
|
+
return {"count": 0, "first": None, "last": None, "min": None,
|
|
33
|
+
"max": None, "returnPct": None}
|
|
34
|
+
first, last = closes[0], closes[-1]
|
|
35
|
+
return_pct = ((last / first - 1.0) * 100.0) if first else None
|
|
36
|
+
return {
|
|
37
|
+
"count": len(closes),
|
|
38
|
+
"first": round(first, 6),
|
|
39
|
+
"last": round(last, 6),
|
|
40
|
+
"min": round(min(closes), 6),
|
|
41
|
+
"max": round(max(closes), 6),
|
|
42
|
+
"returnPct": round(return_pct, 2) if return_pct is not None else None,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def price_series_widget(symbol, interval, range_key, points) -> dict:
|
|
47
|
+
"""points: list of {ts, close}."""
|
|
48
|
+
normalized = [{"ts": p["ts"], "close": float(p["close"])} for p in points]
|
|
49
|
+
return {
|
|
50
|
+
"type": "priceSeries",
|
|
51
|
+
"symbol": symbol,
|
|
52
|
+
"interval": interval,
|
|
53
|
+
"range": range_key,
|
|
54
|
+
"points": normalized,
|
|
55
|
+
"stats": compute_series_stats(normalized),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def backtest_report_widget(symbol, interval, range_key, strategy_type,
|
|
60
|
+
strategy_label, result) -> dict:
|
|
61
|
+
"""result: the engine.run_backtest output
|
|
62
|
+
{equityCurve, trades, metrics}."""
|
|
63
|
+
return {
|
|
64
|
+
"type": "backtestReport",
|
|
65
|
+
"symbol": symbol,
|
|
66
|
+
"interval": interval,
|
|
67
|
+
"range": range_key,
|
|
68
|
+
"strategyType": strategy_type,
|
|
69
|
+
"strategyLabel": strategy_label,
|
|
70
|
+
"equityCurve": result["equityCurve"],
|
|
71
|
+
"trades": result["trades"],
|
|
72
|
+
"metrics": result["metrics"],
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def backtest_compare_widget(symbol, interval, range_key, entries) -> dict:
|
|
77
|
+
"""entries: list of {strategyType, label, result}. Ranked by total return
|
|
78
|
+
(descending) so the best strategy leads the table/legend."""
|
|
79
|
+
built = [
|
|
80
|
+
{
|
|
81
|
+
"strategyType": e["strategyType"],
|
|
82
|
+
"label": e["label"],
|
|
83
|
+
"metrics": e["result"]["metrics"],
|
|
84
|
+
"equityCurve": e["result"]["equityCurve"],
|
|
85
|
+
}
|
|
86
|
+
for e in entries
|
|
87
|
+
]
|
|
88
|
+
built.sort(key=lambda e: e["metrics"].get("totalReturnPct", 0.0), reverse=True)
|
|
89
|
+
return {
|
|
90
|
+
"type": "backtestCompare",
|
|
91
|
+
"symbol": symbol,
|
|
92
|
+
"interval": interval,
|
|
93
|
+
"range": range_key,
|
|
94
|
+
"entries": built,
|
|
95
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Strategy layer: pure indicators, long/flat signal functions, the strategy
|
|
2
|
+
catalog (registry), and the backtest engine. All pure (bars in, results out).
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from backtestchat.strategy.engine import run_backtest, simulate
|
|
6
|
+
from backtestchat.strategy.registry import (
|
|
7
|
+
STRATEGY_REGISTRY,
|
|
8
|
+
describe_strategies,
|
|
9
|
+
is_supported_strategy,
|
|
10
|
+
strategy_label,
|
|
11
|
+
strategy_warmup,
|
|
12
|
+
supported_strategies,
|
|
13
|
+
validate_params,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"STRATEGY_REGISTRY",
|
|
18
|
+
"describe_strategies",
|
|
19
|
+
"is_supported_strategy",
|
|
20
|
+
"run_backtest",
|
|
21
|
+
"simulate",
|
|
22
|
+
"strategy_label",
|
|
23
|
+
"strategy_warmup",
|
|
24
|
+
"supported_strategies",
|
|
25
|
+
"validate_params",
|
|
26
|
+
]
|