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/cli.py ADDED
@@ -0,0 +1,204 @@
1
+ """backtestchat command-line interface (typer).
2
+
3
+ Commands: chat (the agent REPL), runs list/sync (paper runs), show (open a
4
+ rendered chart), config (print resolved settings). Heavy imports (the agent
5
+ stack, broker, store) are done lazily inside each command so `backtestchat --help`
6
+ and `config` stay fast and need no model key.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+
13
+ import typer
14
+ from rich.console import Console
15
+
16
+ from backtestchat import __version__
17
+ from backtestchat.config import ensure_dirs, load_config
18
+
19
+ app = typer.Typer(
20
+ name="backtestchat",
21
+ help="Local-first agentic strategy lab: chat, backtest, and paper-trade "
22
+ "long/flat strategies on keyless Binance public data. Paper-only.",
23
+ no_args_is_help=True,
24
+ add_completion=False,
25
+ )
26
+
27
+ runs_app = typer.Typer(help="Inspect and advance paper runs.", no_args_is_help=True)
28
+ app.add_typer(runs_app, name="runs")
29
+
30
+ console = Console()
31
+
32
+
33
+ def _version_callback(value: bool) -> None:
34
+ if value:
35
+ console.print(f"backtestchat {__version__}")
36
+ raise typer.Exit()
37
+
38
+
39
+ @app.callback()
40
+ def main(
41
+ _version: bool = typer.Option(
42
+ False,
43
+ "--version",
44
+ callback=_version_callback,
45
+ is_eager=True,
46
+ help="Show the backtestchat version and exit.",
47
+ ),
48
+ ) -> None:
49
+ """backtestchat: paper-only agentic strategy lab."""
50
+
51
+
52
+ _SETUP_HELP = """[yellow]No model configured.[/yellow] backtestchat is BYOK: set BACKTESTCHAT_MODEL to a
53
+ "provider:model" string (or put model = "provider:model" in ./backtestchat.toml).
54
+
55
+ Example:
56
+ uv sync --extra openai
57
+ export BACKTESTCHAT_MODEL=openai:gpt-5.4-nano
58
+ export OPENAI_API_KEY=...
59
+
60
+ Any init_chat_model provider works (install its --extra and set the matching
61
+ API key). Market data is keyless; only the model needs a key."""
62
+
63
+
64
+ @app.command()
65
+ def chat(
66
+ new: bool = typer.Option(False, "--new", help="Start a fresh conversation thread."),
67
+ thread: str | None = typer.Option(None, "--thread", help="Resume a specific thread id."),
68
+ ) -> None:
69
+ """Chat with the strategy agent (resumes the last thread by default)."""
70
+ from backtestchat.agent.chat import ModelSetupError, build_session, run_repl
71
+
72
+ cfg = load_config()
73
+ if not cfg.model:
74
+ console.print(_SETUP_HELP)
75
+ raise typer.Exit(1)
76
+
77
+ ensure_dirs(cfg)
78
+ thread_id = thread or (f"t-{uuid.uuid4().hex[:8]}" if new else "default")
79
+
80
+ try:
81
+ session = build_session(cfg)
82
+ except ModelSetupError as exc:
83
+ console.print(f"[red]{exc}[/red]")
84
+ raise typer.Exit(1)
85
+
86
+ try:
87
+ run_repl(session, thread_id, console)
88
+ finally:
89
+ session.close()
90
+
91
+
92
+ def _runs_table(rows: list[dict]):
93
+ from rich.table import Table
94
+
95
+ table = Table(show_header=True, header_style="bold")
96
+ for col in ("id", "symbol", "interval", "strategy", "status", "equity",
97
+ "cash", "position", "realizedPnl", "lastBar"):
98
+ table.add_column(col)
99
+ for r in rows:
100
+ table.add_row(
101
+ str(r["id"]), r["symbol"], r["interval"], r["strategy_type"],
102
+ r["status"], f"{r['equity']:.2f}", f"{r['cash']:.2f}",
103
+ f"{r['position_qty']:.6f}", f"{r['realized_pnl']:.2f}",
104
+ r["last_bar_ts"] or "-",
105
+ )
106
+ return table
107
+
108
+
109
+ @runs_app.command("list")
110
+ def runs_list(
111
+ status: str | None = typer.Option(None, "--status", help="Filter by run status (active/stopped)."),
112
+ ) -> None:
113
+ """List paper runs (stored state; run `backtestchat runs sync` to advance them)."""
114
+ from backtestchat.paper.store import Store
115
+
116
+ cfg = load_config()
117
+ store = Store(cfg.db_path)
118
+ try:
119
+ rows = store.list_runs(status)
120
+ finally:
121
+ store.close()
122
+
123
+ if not rows:
124
+ console.print("[dim]No paper runs yet. Start one from `backtestchat chat`.[/dim]")
125
+ return
126
+ console.print(_runs_table(rows))
127
+ console.print("[dim]Equity/position reflect the last sync. "
128
+ "Run `backtestchat runs sync` to advance to the latest closed bar.[/dim]")
129
+
130
+
131
+ @runs_app.command("sync")
132
+ def runs_sync() -> None:
133
+ """Advance all active paper runs by replaying every closed bar since their
134
+ last tick."""
135
+ from backtestchat.broker.base import BrokerError
136
+ from backtestchat.broker.binance import BinanceBroker
137
+ from backtestchat.paper.runner import sync_all
138
+ from backtestchat.paper.store import Store
139
+
140
+ cfg = load_config()
141
+ ensure_dirs(cfg)
142
+ store = Store(cfg.db_path)
143
+ if not store.list_runs("active"):
144
+ console.print("[dim]No active runs to sync.[/dim]")
145
+ store.close()
146
+ return
147
+
148
+ broker = BinanceBroker(data_dir=cfg.data_dir)
149
+ try:
150
+ results = sync_all(store, broker)
151
+ except BrokerError as exc:
152
+ console.print(f"[red]sync failed:[/red] {exc}")
153
+ raise typer.Exit(1)
154
+ finally:
155
+ broker.close()
156
+
157
+ for r in results:
158
+ note = f" ({r['note']})" if r.get("note") else ""
159
+ console.print(
160
+ f"run {r['run_id']}: [bold]{r['status']}[/bold], "
161
+ f"{r['bars_processed']} bar(s) processed, equity {r['equity']:.2f}"
162
+ f"{note}"
163
+ )
164
+ console.print(_runs_table(store.list_runs()))
165
+ store.close()
166
+
167
+
168
+ @app.command()
169
+ def show(
170
+ target: str = typer.Argument("last", help="Artifact to open: 'last', a filename, or a path."),
171
+ ) -> None:
172
+ """Open a rendered chart artifact in the browser."""
173
+ import webbrowser
174
+
175
+ from backtestchat.artifacts import resolve_artifact
176
+
177
+ cfg = load_config()
178
+ path = resolve_artifact(cfg, target)
179
+ if path is None:
180
+ console.print(f"[red]No artifact found for '{target}'.[/red] "
181
+ f"Charts are written to {cfg.artifacts_dir} during chat.")
182
+ raise typer.Exit(1)
183
+
184
+ console.print(f"opening {path}")
185
+ try:
186
+ webbrowser.open(path.resolve().as_uri())
187
+ except Exception as exc: # noqa: BLE001 - headless env
188
+ console.print(f"[yellow]could not open a browser ({exc}); the file is at "
189
+ f"{path}[/yellow]")
190
+
191
+
192
+ @app.command()
193
+ def config() -> None:
194
+ """Print the resolved configuration."""
195
+ cfg = load_config()
196
+ console.print("[bold]backtestchat config[/bold]")
197
+ console.print(f" model : {cfg.model or '[red]unset[/red] (set BACKTESTCHAT_MODEL)'}")
198
+ console.print(f" data_dir : {cfg.data_dir}")
199
+ console.print(f" artifacts_dir : {cfg.artifacts_dir}")
200
+ console.print(f" db_path : {cfg.db_path}")
201
+
202
+
203
+ if __name__ == "__main__":
204
+ app()
backtestchat/config.py ADDED
@@ -0,0 +1,89 @@
1
+ """Configuration resolution for backtestchat.
2
+
3
+ Resolution order (highest priority first):
4
+ 1. Environment variables
5
+ 2. ./backtestchat.toml (in the current working directory)
6
+ 3. Built-in defaults
7
+
8
+ Keys:
9
+ BACKTESTCHAT_MODEL provider:model string for init_chat_model (BYOK).
10
+ No default: an unset model is a hard setup error.
11
+ BACKTESTCHAT_DATA_DIR sqlite + checkpoint dir. Default ./data
12
+ BACKTESTCHAT_ARTIFACTS_DIR chart HTML output dir. Default ./artifacts
13
+
14
+ The toml file uses lowercase, unprefixed keys, e.g.:
15
+
16
+ model = "openai:gpt-5.4-nano"
17
+ data_dir = "./data"
18
+ artifacts_dir = "./artifacts"
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import tomllib
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ _CONFIG_FILENAME = "backtestchat.toml"
29
+
30
+ # key -> (env var name, toml key, default)
31
+ _FIELDS = {
32
+ "model": ("BACKTESTCHAT_MODEL", "model", None),
33
+ "data_dir": ("BACKTESTCHAT_DATA_DIR", "data_dir", "./data"),
34
+ "artifacts_dir": ("BACKTESTCHAT_ARTIFACTS_DIR", "artifacts_dir", "./artifacts"),
35
+ }
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Config:
40
+ """Resolved backtestchat configuration."""
41
+
42
+ model: str | None
43
+ data_dir: Path
44
+ artifacts_dir: Path
45
+
46
+ @property
47
+ def db_path(self) -> Path:
48
+ """Path to the application sqlite database."""
49
+ return self.data_dir / "backtestchat.sqlite3"
50
+
51
+ @property
52
+ def checkpoint_path(self) -> Path:
53
+ """Path to the LangGraph SqliteSaver checkpoint database."""
54
+ return self.data_dir / "checkpoints.sqlite3"
55
+
56
+
57
+ def _load_toml(cwd: Path) -> dict:
58
+ path = cwd / _CONFIG_FILENAME
59
+ if not path.is_file():
60
+ return {}
61
+ with path.open("rb") as fh:
62
+ return tomllib.load(fh)
63
+
64
+
65
+ def load_config(cwd: Path | None = None) -> Config:
66
+ """Resolve configuration from env, ./backtestchat.toml, then defaults."""
67
+ cwd = cwd or Path.cwd()
68
+ toml_data = _load_toml(cwd)
69
+
70
+ resolved: dict[str, str | None] = {}
71
+ for key, (env_name, toml_key, default) in _FIELDS.items():
72
+ value = os.environ.get(env_name)
73
+ if value is None:
74
+ value = toml_data.get(toml_key)
75
+ if value is None:
76
+ value = default
77
+ resolved[key] = value
78
+
79
+ return Config(
80
+ model=resolved["model"],
81
+ data_dir=Path(resolved["data_dir"]).expanduser(),
82
+ artifacts_dir=Path(resolved["artifacts_dir"]).expanduser(),
83
+ )
84
+
85
+
86
+ def ensure_dirs(config: Config) -> None:
87
+ """Create the runtime data and artifacts directories if missing."""
88
+ config.data_dir.mkdir(parents=True, exist_ok=True)
89
+ config.artifacts_dir.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,22 @@
1
+ """Paper-trading layer: the sqlite store and the replay-based run advancer.
2
+
3
+ Single user, float money, and scheduler-free replay advancement.
4
+ """
5
+
6
+ from backtestchat.paper.runner import (
7
+ DEFAULT_STARTING_CASH,
8
+ create_run,
9
+ stop_run,
10
+ sync_all,
11
+ sync_run,
12
+ )
13
+ from backtestchat.paper.store import Store
14
+
15
+ __all__ = [
16
+ "DEFAULT_STARTING_CASH",
17
+ "Store",
18
+ "create_run",
19
+ "stop_run",
20
+ "sync_all",
21
+ "sync_run",
22
+ ]
@@ -0,0 +1,231 @@
1
+ """Paper-run lifecycle and replay-based advancement.
2
+
3
+ There is no scheduler: advancing REPLAYS every closed bar since the run's last
4
+ processed bar, in chronological order, one signal evaluation per bar. The
5
+ "already processed this bar ts" guard is the only cadence control, so any CLI
6
+ invocation may safely trigger a sync at any time (idempotent: a sync with no new
7
+ closed bars changes nothing).
8
+
9
+ Key design points:
10
+ - Single user; storage is the hand-rolled sqlite Store.
11
+ - Money is float, not Decimal (fine for a paper sim).
12
+ - Fills are fractional and fully allocated: a buy converts ALL cash to
13
+ position at the bar close (qty = cash / price), a sell converts it all
14
+ back (whole-unit fills would floor to 0 for a $60k asset with $10k cash).
15
+ - First-tick rule: no backdated fills. The first bar a run may trade is the
16
+ first bar that CLOSES after the run was created.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import time
22
+
23
+ from backtestchat.broker.base import (
24
+ INTERVAL_MS,
25
+ iso_to_ms,
26
+ ms_to_iso,
27
+ validate_interval,
28
+ )
29
+ from backtestchat.paper.store import Store
30
+ from backtestchat.strategy.registry import (
31
+ get_signal_fn,
32
+ strategy_warmup,
33
+ validate_params,
34
+ )
35
+
36
+ DEFAULT_STARTING_CASH = 10_000.0
37
+
38
+ # Extra bars fetched before the anchor so indicators are warm at the first
39
+ # unprocessed bar.
40
+ _WARMUP_BUFFER = 5
41
+
42
+
43
+ def _now_ms() -> int:
44
+ return int(time.time() * 1000)
45
+
46
+
47
+ def create_run(store: Store, broker, *, symbol, interval, strategy_type,
48
+ params=None, cash=DEFAULT_STARTING_CASH, strategy_id=None,
49
+ created_ms=None, now_ms=None) -> dict:
50
+ """Create an active paper run and run its first sync.
51
+
52
+ No backdated fills: the run starts flat and waits for the first bar to
53
+ close after `created_ms` (default now). Passing a past `created_ms` (with
54
+ `now_ms` at the present) is how a test or a demo replays historical bars.
55
+ Returns the first sync result dict (which carries the new run_id).
56
+ """
57
+ symbol = broker.validate_symbol(symbol)
58
+ validate_interval(interval)
59
+ params = validate_params(strategy_type, params or {})
60
+
61
+ created_ms = created_ms if created_ms is not None else _now_ms()
62
+ run_id = store.create_run(
63
+ strategy_id=strategy_id,
64
+ strategy_type=strategy_type,
65
+ params=params,
66
+ symbol=symbol,
67
+ interval=interval,
68
+ cash_start=float(cash),
69
+ created_at=ms_to_iso(created_ms),
70
+ )
71
+ return sync_run(store, broker, run_id, now_ms=now_ms if now_ms is not None else created_ms)
72
+
73
+
74
+ def sync_run(store: Store, broker, run_id, now_ms=None) -> dict:
75
+ """Advance one run by replaying all closed bars since its last processed bar.
76
+
77
+ Returns {run_id, status, bars_processed, trades, position_qty, cash, equity,
78
+ last_bar_ts, note}. status is one of: inactive, no_data, waiting_first_tick,
79
+ up_to_date, advanced.
80
+ """
81
+ now_ms = now_ms if now_ms is not None else _now_ms()
82
+ run = store.get_run(run_id)
83
+ if run is None:
84
+ return {"run_id": run_id, "status": "not_found"}
85
+ if run["status"] != "active":
86
+ return _result(run, "inactive", 0, [])
87
+
88
+ interval = run["interval"]
89
+ params = run["params"]
90
+ warmup = strategy_warmup(run["strategy_type"], params)
91
+ interval_ms = INTERVAL_MS[interval]
92
+
93
+ last_bar_ts = run["last_bar_ts"]
94
+ created_ms = iso_to_ms(run["created_at"])
95
+ anchor_ms = iso_to_ms(last_bar_ts) if last_bar_ts else created_ms
96
+ start_ms = anchor_ms - (warmup + _WARMUP_BUFFER) * interval_ms
97
+
98
+ bars = broker.get_bars(run["symbol"], interval, start_ms=start_ms, now_ms=now_ms)
99
+ if not bars:
100
+ return _result(run, "no_data", 0, [])
101
+
102
+ # Which fetched bars are unprocessed?
103
+ if last_bar_ts is None:
104
+ # First tick: a bar is eligible once it CLOSES after creation. Bars are
105
+ # stamped at open time, so close = open + interval.
106
+ unprocessed = [
107
+ i for i, bar in enumerate(bars)
108
+ if iso_to_ms(bar["ts"]) + interval_ms > created_ms
109
+ ]
110
+ else:
111
+ unprocessed = [i for i, bar in enumerate(bars) if bar["ts"] > last_bar_ts]
112
+
113
+ if not unprocessed:
114
+ status = "waiting_first_tick" if last_bar_ts is None else "up_to_date"
115
+ note = ("Waiting for the first bar to close after the run was created."
116
+ if last_bar_ts is None else run["note"])
117
+ if note != run["note"]:
118
+ store.update_run(run_id, note=note)
119
+ run["note"] = note
120
+ return _result(run, status, 0, [])
121
+
122
+ # Signals are trailing-only (no look-ahead), so evaluating once over the
123
+ # full fetched window and indexing is equivalent to per-bar incremental
124
+ # evaluation.
125
+ signal_fn = get_signal_fn(run["strategy_type"])
126
+ signals = signal_fn(bars, params)
127
+
128
+ cash = run["cash"]
129
+ qty = run["position_qty"]
130
+ avg = run["position_avg_price"]
131
+ entry_ts = run["position_entry_ts"]
132
+ realized = run["realized_pnl"]
133
+ last_price = run["last_price"]
134
+ new_trades = []
135
+
136
+ for i in unprocessed:
137
+ bar = bars[i]
138
+ price = bar["close"]
139
+ target = signals[i] if i < len(signals) else 0
140
+
141
+ if target == 1 and qty == 0 and cash > 0:
142
+ buy_qty = cash / price
143
+ cash = 0.0
144
+ qty = buy_qty
145
+ avg = price
146
+ entry_ts = bar["ts"]
147
+ equity = qty * price
148
+ new_trades.append(_trade("buy", bar["ts"], price, buy_qty, equity))
149
+ elif target == 0 and qty > 0:
150
+ proceeds = qty * price
151
+ realized += (price - (avg or price)) * qty
152
+ cash += proceeds
153
+ sell_qty = qty
154
+ qty = 0.0
155
+ avg = None
156
+ entry_ts = None
157
+ equity = cash
158
+ new_trades.append(_trade("sell", bar["ts"], price, sell_qty, equity))
159
+
160
+ last_price = price
161
+ last_bar_ts = bar["ts"]
162
+
163
+ equity = cash + qty * (last_price or 0.0)
164
+
165
+ for trade in new_trades:
166
+ store.append_trade(run_id, trade["side"], trade["ts"], trade["price"],
167
+ trade["qty"], trade["equity_after"])
168
+
169
+ store.update_run(
170
+ run_id,
171
+ cash=cash,
172
+ position_qty=qty,
173
+ position_avg_price=avg,
174
+ position_entry_ts=entry_ts,
175
+ last_price=last_price,
176
+ equity=equity,
177
+ realized_pnl=realized,
178
+ last_bar_ts=last_bar_ts,
179
+ last_tick_at=ms_to_iso(now_ms),
180
+ note="",
181
+ )
182
+
183
+ run = store.get_run(run_id)
184
+ return _result(run, "advanced", len(unprocessed), new_trades)
185
+
186
+
187
+ def sync_all(store: Store, broker, now_ms=None) -> list[dict]:
188
+ """Sync every active run. Returns a result dict per run."""
189
+ return [
190
+ sync_run(store, broker, run["id"], now_ms=now_ms)
191
+ for run in store.list_runs(status="active")
192
+ ]
193
+
194
+
195
+ def stop_run(store: Store, broker, run_id, now_ms=None) -> dict:
196
+ """Sync a run one last time, then mark it stopped. Returns a final summary."""
197
+ result = sync_run(store, broker, run_id, now_ms=now_ms)
198
+ run = store.get_run(run_id)
199
+ if run is None:
200
+ return {"run_id": run_id, "status": "not_found"}
201
+ if run["status"] == "active":
202
+ store.update_run(run_id, status="stopped")
203
+ run["status"] = "stopped"
204
+ summary = _result(run, "stopped", result.get("bars_processed", 0),
205
+ result.get("trades", []))
206
+ summary["realized_pnl"] = run["realized_pnl"]
207
+ summary["trade_count"] = len(store.list_trades(run_id))
208
+ return summary
209
+
210
+
211
+ def _trade(side, ts, price, qty, equity_after) -> dict:
212
+ return {"side": side, "ts": ts, "price": price, "qty": qty,
213
+ "equity_after": equity_after}
214
+
215
+
216
+ def _result(run, status, bars_processed, trades) -> dict:
217
+ return {
218
+ "run_id": run["id"],
219
+ "status": status,
220
+ "bars_processed": bars_processed,
221
+ "trades": trades,
222
+ "symbol": run["symbol"],
223
+ "interval": run["interval"],
224
+ "strategy_type": run["strategy_type"],
225
+ "position_qty": run["position_qty"],
226
+ "cash": run["cash"],
227
+ "equity": run["equity"],
228
+ "realized_pnl": run["realized_pnl"],
229
+ "last_bar_ts": run["last_bar_ts"],
230
+ "note": run["note"],
231
+ }