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,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))
|