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,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
+ ]