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,247 @@
1
+ """Broker interface plus pure frequency/window logic and structured errors.
2
+
3
+ Frequency definitions plus range-key / window-resolution logic. backtestchat is
4
+ Binance-only crypto, and the canonical frequency names ARE the Binance interval
5
+ strings (no asset-class or resample mapping needed).
6
+
7
+ Everything here is pure (no I/O, no network). The concrete Binance client lives
8
+ in broker/binance.py and implements the Broker ABC.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from abc import ABC, abstractmethod
15
+ from datetime import datetime, timezone
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Frequencies (Binance interval strings are canonical)
19
+ # ---------------------------------------------------------------------------
20
+
21
+ # Supported strategy/paper intervals. Internal names are identical to Binance
22
+ # interval strings, so no resample map is needed.
23
+ SUPPORTED_INTERVALS: tuple[str, ...] = ("15m", "1h", "4h", "1d")
24
+
25
+ # Bars per year for annualization. Crypto trades 24/7/365.
26
+ BARS_PER_YEAR: dict[str, int] = {
27
+ "15m": 35040, # 365 * 24 * 4
28
+ "1h": 8760, # 365 * 24
29
+ "4h": 2190, # 365 * 6
30
+ "1d": 365,
31
+ }
32
+
33
+ # Milliseconds per bar, used for kline pagination and close-time math.
34
+ INTERVAL_MS: dict[str, int] = {
35
+ "15m": 15 * 60 * 1000,
36
+ "1h": 60 * 60 * 1000,
37
+ "4h": 4 * 60 * 60 * 1000,
38
+ "1d": 24 * 60 * 60 * 1000,
39
+ }
40
+
41
+ DAY_MS = 24 * 60 * 60 * 1000
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Range keys (fetch-window resolution)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ # Named range keys -> calendar days. Ported from v4 market_service.RANGE_DAYS
48
+ # (month = 31, year = 12 * 31 = 372) so a named key and its generic Nm/Ny form
49
+ # resolve identically.
50
+ NAMED_RANGE_DAYS: dict[str, int] = {
51
+ "1m": 31,
52
+ "3m": 93,
53
+ "6m": 186,
54
+ "1y": 372,
55
+ "2y": 744,
56
+ "5y": 1830,
57
+ }
58
+
59
+ # Generic "Nd" / "Nw" / "Nm" / "Ny" units, in calendar days.
60
+ _UNIT_DAYS = {"d": 1, "w": 7, "m": 31, "y": 372}
61
+ _RANGE_RE = re.compile(r"^(\d+)([dwmy])$")
62
+
63
+ _RANGE_HINT = (
64
+ "Use a named key (1m, 3m, 6m, 1y, 2y, 5y) or an Nd/Nw/Nm/Ny form "
65
+ "(e.g. 30d, 6w, 18m, 3y)."
66
+ )
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Structured broker errors
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ class BrokerError(Exception):
75
+ """Network/HTTP failure or a non-200 response. Message is surfaced
76
+ verbatim; the tool layer wraps it into a structured error dict."""
77
+
78
+
79
+ class UnknownSymbolError(BrokerError):
80
+ """Symbol is not in the exchange's tradable set."""
81
+
82
+ def __init__(self, symbol: str, suggestions: list[str]):
83
+ self.symbol = symbol
84
+ self.suggestions = suggestions
85
+ message = f"Unknown symbol '{symbol}'."
86
+ if suggestions:
87
+ message += f" Did you mean: {', '.join(suggestions)}?"
88
+ super().__init__(message)
89
+
90
+
91
+ class UnknownIntervalError(BrokerError):
92
+ """Interval is outside SUPPORTED_INTERVALS."""
93
+
94
+ def __init__(self, interval: str):
95
+ self.interval = interval
96
+ self.valid_options = list(SUPPORTED_INTERVALS)
97
+ super().__init__(
98
+ f"Unsupported interval '{interval}'. "
99
+ f"Valid intervals: {', '.join(SUPPORTED_INTERVALS)}."
100
+ )
101
+
102
+
103
+ class UnknownRangeError(BrokerError):
104
+ """Range key cannot be resolved to a window."""
105
+
106
+ def __init__(self, range_key: str):
107
+ self.range_key = range_key
108
+ self.valid_options = list(NAMED_RANGE_DAYS)
109
+ super().__init__(f"Unknown range key '{range_key}'. {_RANGE_HINT}")
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Pure helpers
114
+ # ---------------------------------------------------------------------------
115
+
116
+
117
+ def validate_interval(interval: str) -> str:
118
+ """Return the interval if supported, else raise UnknownIntervalError."""
119
+ if interval not in INTERVAL_MS:
120
+ raise UnknownIntervalError(interval)
121
+ return interval
122
+
123
+
124
+ def bars_per_year(interval: str) -> int:
125
+ """Bars per year for annualization (crypto trades 24/7/365).
126
+
127
+ Crypto only (24/7/365), so there is no asset-class branch.
128
+ """
129
+ validate_interval(interval)
130
+ return BARS_PER_YEAR[interval]
131
+
132
+
133
+ def resolve_range_days(range_key: str) -> int:
134
+ """Resolve a range key to a positive number of calendar days.
135
+
136
+ Accepts named keys (1m/3m/6m/1y/2y/5y) or a generic Nd/Nw/Nm/Ny form.
137
+ Raises UnknownRangeError on anything else.
138
+ """
139
+ if range_key in NAMED_RANGE_DAYS:
140
+ return NAMED_RANGE_DAYS[range_key]
141
+
142
+ match = _RANGE_RE.match(range_key or "")
143
+ if match:
144
+ amount = int(match.group(1))
145
+ if amount > 0:
146
+ return amount * _UNIT_DAYS[match.group(2)]
147
+
148
+ raise UnknownRangeError(range_key)
149
+
150
+
151
+ def ms_to_iso(ms: int) -> str:
152
+ """Epoch milliseconds -> ISO-8601 UTC string, e.g. 2026-07-03T14:00:00Z."""
153
+ dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
154
+ return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
155
+
156
+
157
+ def iso_to_ms(iso: str) -> int:
158
+ """Inverse of ms_to_iso: ISO-8601 UTC string -> epoch milliseconds.
159
+
160
+ Accepts the canonical ...Z form this codebase produces; a trailing Z or
161
+ +00:00 offset is treated as UTC.
162
+ """
163
+ text = (iso or "").strip().replace("Z", "+00:00")
164
+ dt = datetime.fromisoformat(text)
165
+ if dt.tzinfo is None:
166
+ dt = dt.replace(tzinfo=timezone.utc)
167
+ return int(dt.timestamp() * 1000)
168
+
169
+
170
+ def kline_to_bar(kline: list) -> dict:
171
+ """Convert a raw Binance kline array to the internal bar contract.
172
+
173
+ Binance kline layout: [openTime, open, high, low, close, volume,
174
+ closeTime, quoteAssetVolume, numTrades, ...]. ts is derived from openTime.
175
+ All price/volume fields are floats (Decimal-free; fine for a paper sim).
176
+ """
177
+ return {
178
+ "ts": ms_to_iso(int(kline[0])),
179
+ "open": float(kline[1]),
180
+ "high": float(kline[2]),
181
+ "low": float(kline[3]),
182
+ "close": float(kline[4]),
183
+ "volume": float(kline[5]),
184
+ }
185
+
186
+
187
+ def drop_forming_klines(klines: list[list], now_ms: int) -> list[list]:
188
+ """Keep only closed klines (closeTime, index 6, strictly before now).
189
+
190
+ Binance returns historical closed candles plus, at the tail, the current
191
+ still-forming candle whose closeTime is in the future. Only closed bars may
192
+ enter the engine, paper runner, or charts.
193
+ """
194
+ return [k for k in klines if int(k[6]) < now_ms]
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # Broker interface
199
+ # ---------------------------------------------------------------------------
200
+
201
+
202
+ class Broker(ABC):
203
+ """Market-data source interface. Testnet order routing would be a clean
204
+ adapter add here in v2, but v1 is read-only market data only."""
205
+
206
+ @abstractmethod
207
+ def get_bars(
208
+ self,
209
+ symbol: str,
210
+ interval: str,
211
+ range_key: str | None = None,
212
+ start_ms: int | None = None,
213
+ now_ms: int | None = None,
214
+ ) -> list[dict]:
215
+ """Return closed OHLCV bars (internal bar contract) for the window."""
216
+
217
+ @abstractmethod
218
+ def get_quote(self, symbol: str) -> dict:
219
+ """Return the latest quote for a symbol."""
220
+
221
+ @abstractmethod
222
+ def symbols(self) -> set[str]:
223
+ """Return the set of tradable symbols."""
224
+
225
+ def validate_symbol(self, symbol: str) -> str:
226
+ """Normalize (uppercase) and validate a symbol against symbols().
227
+
228
+ Returns the normalized symbol or raises UnknownSymbolError with
229
+ suggestions. Concrete brokers may override symbols() caching.
230
+ """
231
+ normalized = (symbol or "").strip().upper()
232
+ known = self.symbols()
233
+ if normalized in known:
234
+ return normalized
235
+ raise UnknownSymbolError(normalized, self._suggest(normalized, known))
236
+
237
+ @staticmethod
238
+ def _suggest(symbol: str, known: set[str], limit: int = 5) -> list[str]:
239
+ """Best-effort suggestions: symbols sharing a leading prefix."""
240
+ if not symbol:
241
+ return []
242
+ for width in (4, 3, 2):
243
+ prefix = symbol[:width]
244
+ matches = sorted(s for s in known if s.startswith(prefix))
245
+ if matches:
246
+ return matches[:limit]
247
+ return []
@@ -0,0 +1,290 @@
1
+ """Keyless Binance public-REST market-data client.
2
+
3
+ A thin client (in-process TTL caches, loud errors) over the public keyless
4
+ Binance spot API using httpx. No API key is ever sent. Endpoints used:
5
+
6
+ GET /api/v3/klines OHLCV bars (limit 1000, paginate by startTime)
7
+ GET /api/v3/ticker/24hr rolling 24h quote
8
+ GET /api/v3/exchangeInfo tradable symbol set (cached 24h to a data/ file)
9
+
10
+ TTL caches (per process): quote 30s, klines 60s per (symbol, interval, range).
11
+ Network failures retry twice with a short backoff; non-200 responses and
12
+ exhausted retries raise BrokerError with the HTTP detail surfaced verbatim.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import time
19
+ from pathlib import Path
20
+
21
+ import httpx
22
+
23
+ from backtestchat.broker.base import (
24
+ DAY_MS,
25
+ INTERVAL_MS,
26
+ Broker,
27
+ BrokerError,
28
+ drop_forming_klines,
29
+ kline_to_bar,
30
+ ms_to_iso,
31
+ resolve_range_days,
32
+ validate_interval,
33
+ )
34
+ from backtestchat.config import load_config
35
+
36
+ BASE_URL = "https://api.binance.com"
37
+ KLINES_LIMIT = 1000
38
+ DEFAULT_RANGE_KEY = "1y"
39
+
40
+ QUOTE_TTL = 30 # seconds
41
+ KLINES_TTL = 60 # seconds
42
+ EXCHANGE_INFO_TTL = 24 * 60 * 60 # seconds (cached to a data/ file)
43
+
44
+ _HTTP_TIMEOUT = 15.0
45
+ _RETRIES = 2
46
+ _BACKOFF = 0.5 # seconds, multiplied by attempt number
47
+
48
+
49
+ class BinanceBroker(Broker):
50
+ """Public keyless Binance spot broker."""
51
+
52
+ def __init__(
53
+ self,
54
+ data_dir: Path | None = None,
55
+ client: httpx.Client | None = None,
56
+ known_symbols: set[str] | None = None,
57
+ ):
58
+ self._data_dir = data_dir or load_config().data_dir
59
+ self._client = client or httpx.Client(base_url=BASE_URL, timeout=_HTTP_TIMEOUT)
60
+ self._owns_client = client is None
61
+ # In-process caches.
62
+ self._cache: dict = {}
63
+ self._symbols: set[str] | None = set(known_symbols) if known_symbols else None
64
+
65
+ # -- lifecycle ---------------------------------------------------------
66
+
67
+ def close(self) -> None:
68
+ if self._owns_client:
69
+ self._client.close()
70
+
71
+ def __enter__(self) -> "BinanceBroker":
72
+ return self
73
+
74
+ def __exit__(self, *exc) -> None:
75
+ self.close()
76
+
77
+ # -- clock (overridable for tests) ------------------------------------
78
+
79
+ @staticmethod
80
+ def _now_ms() -> int:
81
+ return int(time.time() * 1000)
82
+
83
+ # -- TTL cache ---------------------------------------------------------
84
+
85
+ def _cache_get(self, key: tuple, ttl: float):
86
+ entry = self._cache.get(key)
87
+ if not entry:
88
+ return None
89
+ stored_at, value = entry
90
+ if time.time() - stored_at > ttl:
91
+ self._cache.pop(key, None)
92
+ return None
93
+ return value
94
+
95
+ def _cache_set(self, key: tuple, value) -> None:
96
+ self._cache[key] = (time.time(), value)
97
+
98
+ # -- HTTP with retry ---------------------------------------------------
99
+
100
+ def _get(self, path: str, params: dict | None = None):
101
+ last_error: Exception | None = None
102
+ for attempt in range(1, _RETRIES + 1):
103
+ try:
104
+ response = self._client.get(path, params=params or {})
105
+ except httpx.HTTPError as error:
106
+ last_error = error
107
+ if attempt < _RETRIES:
108
+ time.sleep(_BACKOFF * attempt)
109
+ continue
110
+
111
+ if response.status_code == 200:
112
+ try:
113
+ return response.json()
114
+ except ValueError as error:
115
+ raise BrokerError(
116
+ f"Binance returned a non-JSON body for {path}"
117
+ ) from error
118
+
119
+ # Non-200: surface verbatim, do not retry (e.g. 400 bad params).
120
+ raise BrokerError(
121
+ f"Binance {response.status_code} for {path}: "
122
+ f"{response.text[:300]}"
123
+ )
124
+
125
+ raise BrokerError(f"Binance request to {path} failed: {last_error}")
126
+
127
+ # -- symbols -----------------------------------------------------------
128
+
129
+ def symbols(self) -> set[str]:
130
+ if self._symbols is not None:
131
+ return self._symbols
132
+
133
+ cached = self._load_symbol_file()
134
+ if cached is not None:
135
+ self._symbols = cached
136
+ return cached
137
+
138
+ data = self._get("/api/v3/exchangeInfo")
139
+ symbols = {
140
+ entry["symbol"]
141
+ for entry in data.get("symbols", [])
142
+ if entry.get("status") == "TRADING"
143
+ }
144
+ self._symbols = symbols
145
+ self._write_symbol_file(symbols)
146
+ return symbols
147
+
148
+ def _symbol_file(self) -> Path:
149
+ return self._data_dir / "exchange_info.json"
150
+
151
+ def _load_symbol_file(self) -> set[str] | None:
152
+ path = self._symbol_file()
153
+ if not path.is_file():
154
+ return None
155
+ try:
156
+ payload = json.loads(path.read_text())
157
+ except (ValueError, OSError):
158
+ return None
159
+ if time.time() - payload.get("fetched_at", 0) > EXCHANGE_INFO_TTL:
160
+ return None
161
+ return set(payload.get("symbols", []))
162
+
163
+ def _write_symbol_file(self, symbols: set[str]) -> None:
164
+ try:
165
+ self._data_dir.mkdir(parents=True, exist_ok=True)
166
+ self._symbol_file().write_text(
167
+ json.dumps({"fetched_at": time.time(), "symbols": sorted(symbols)})
168
+ )
169
+ except OSError:
170
+ # Cache write is best-effort; a read-only data dir must not break
171
+ # data fetching.
172
+ pass
173
+
174
+ # -- bars --------------------------------------------------------------
175
+
176
+ def get_bars(
177
+ self,
178
+ symbol: str,
179
+ interval: str,
180
+ range_key: str | None = None,
181
+ start_ms: int | None = None,
182
+ now_ms: int | None = None,
183
+ ) -> list[dict]:
184
+ symbol = self.validate_symbol(symbol)
185
+ validate_interval(interval)
186
+ now = now_ms if now_ms is not None else self._now_ms()
187
+
188
+ # Explicit start (paper-run replay): never cached, window shifts.
189
+ if start_ms is not None:
190
+ return self._bars_since(symbol, interval, start_ms, now)
191
+
192
+ range_key = range_key or DEFAULT_RANGE_KEY
193
+ cache_key = ("bars", symbol, interval, range_key)
194
+ cached = self._cache_get(cache_key, KLINES_TTL)
195
+ if cached is not None:
196
+ return cached
197
+
198
+ days = resolve_range_days(range_key)
199
+ start = now - days * DAY_MS
200
+ bars = self._bars_since(symbol, interval, start, now)
201
+ self._cache_set(cache_key, bars)
202
+ return bars
203
+
204
+ def _bars_since(
205
+ self, symbol: str, interval: str, start_ms: int, now_ms: int
206
+ ) -> list[dict]:
207
+ raw = self._fetch_klines(symbol, interval, start_ms, now_ms)
208
+ closed = drop_forming_klines(raw, now_ms)
209
+ return [kline_to_bar(k) for k in closed]
210
+
211
+ def _fetch_klines(
212
+ self, symbol: str, interval: str, start_ms: int, end_ms: int
213
+ ) -> list[list]:
214
+ """Paginate /api/v3/klines by startTime until end_ms or exhaustion."""
215
+ step = INTERVAL_MS[interval]
216
+ out: list[list] = []
217
+ cursor = start_ms
218
+ while cursor < end_ms:
219
+ batch = self._get(
220
+ "/api/v3/klines",
221
+ params={
222
+ "symbol": symbol,
223
+ "interval": interval,
224
+ "startTime": cursor,
225
+ "limit": KLINES_LIMIT,
226
+ },
227
+ )
228
+ if not batch:
229
+ break
230
+ out.extend(batch)
231
+ if len(batch) < KLINES_LIMIT:
232
+ break
233
+ next_cursor = int(batch[-1][0]) + step
234
+ if next_cursor <= cursor: # safety against non-advancing pagination
235
+ break
236
+ cursor = next_cursor
237
+ return out
238
+
239
+ # -- quote -------------------------------------------------------------
240
+
241
+ def get_quote(self, symbol: str) -> dict:
242
+ symbol = self.validate_symbol(symbol)
243
+ cache_key = ("quote", symbol)
244
+ cached = self._cache_get(cache_key, QUOTE_TTL)
245
+ if cached is not None:
246
+ return cached
247
+
248
+ data = self._get("/api/v3/ticker/24hr", params={"symbol": symbol})
249
+ result = {
250
+ "symbol": symbol,
251
+ "price": float(data["lastPrice"]),
252
+ "changePct": round(float(data["priceChangePercent"]), 4),
253
+ "prevClose": float(data["prevClosePrice"]),
254
+ "high": float(data["highPrice"]),
255
+ "low": float(data["lowPrice"]),
256
+ "volume": float(data["volume"]),
257
+ "asOf": ms_to_iso(int(data["closeTime"])),
258
+ }
259
+ self._cache_set(cache_key, result)
260
+ return result
261
+
262
+
263
+ # ---------------------------------------------------------------------------
264
+ # Smoke CLI: python -m backtestchat.broker.binance BTCUSDT 1h 1m
265
+ # ---------------------------------------------------------------------------
266
+
267
+
268
+ def _smoke(argv: list[str]) -> int:
269
+ if len(argv) < 2:
270
+ print("usage: python -m backtestchat.broker.binance SYMBOL [INTERVAL] [RANGE_KEY]")
271
+ return 2
272
+ symbol = argv[1]
273
+ interval = argv[2] if len(argv) > 2 else "1h"
274
+ range_key = argv[3] if len(argv) > 3 else "1m"
275
+
276
+ with BinanceBroker() as broker:
277
+ quote = broker.get_quote(symbol)
278
+ print(f"quote {quote['symbol']}: {quote['price']} ({quote['changePct']}% 24h)")
279
+ bars = broker.get_bars(symbol, interval, range_key=range_key)
280
+ print(f"bars {symbol} {interval} {range_key}: {len(bars)} closed bars")
281
+ if bars:
282
+ print(f" first: {bars[0]['ts']} close={bars[0]['close']}")
283
+ print(f" last: {bars[-1]['ts']} close={bars[-1]['close']}")
284
+ return 0
285
+
286
+
287
+ if __name__ == "__main__":
288
+ import sys
289
+
290
+ raise SystemExit(_smoke(sys.argv))