-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathfetch_bitget.py
More file actions
213 lines (179 loc) · 7.97 KB
/
Copy pathfetch_bitget.py
File metadata and controls
213 lines (179 loc) · 7.97 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
"""
Data puller for Bitget Reality Spot Stock API - 10 rTokens covering a
mix of volatility profiles. Public endpoint, no API key required.
https://www.bitget.com/docs/catalog/market/market-data#get-tickers
Audit fix (L-8) vs the original version:
- Reuses a single requests.Session across all 10 symbols instead of
opening a fresh TLS connection per request.
- Retries each request with exponential backoff on 429 / 5xx /
connection errors, instead of giving up after one attempt. A
transient rate-limit no longer silently blanks a token for a whole
4-hour cycle.
"""
import time
import math
import requests
from datetime import datetime, timezone
BASE_URL = "https://api.bitget.com/api/v3/market"
MAX_RETRIES = 3 # total attempts per symbol
BACKOFF_BASE_SECONDS = 2 # 2s, 4s between retries
MAX_MARKET_DATA_AGE_SECONDS = 15 * 60
# L-8 fix: one shared session (connection pooling) for all symbols.
SESSION = requests.Session()
SYMBOLS = {
"NVDA": "rNVDAUSDT",
"TSLA": "rTSLAUSDT",
"AAPL": "rAAPLUSDT",
"AMZN": "rAMZNUSDT",
"GOOGL": "rGOOGLUSDT",
"SPY": "rSPYUSDT",
"QQQ": "rQQQUSDT",
"KO": "rKOUSDT",
"MCD": "rMCDUSDT",
"PYPL": "rPYPLUSDT",
}
def fetch_ticker(symbol: str) -> dict:
"""Fetch full ticker: lastPrice, bid1/ask1 price+size, volume24h.
Retries up to MAX_RETRIES times with linear backoff on 429, 5xx
and connection-level errors (timeouts, resets). Non-retryable 4xx
responses (bad symbol, forbidden...) fail immediately instead of
burning three attempts. The last error is re-raised when retries
are exhausted (the caller converts it to a per-symbol error entry)."""
url = f"{BASE_URL}/tickers"
params = {"category": "SPOT", "symbol": symbol}
last_err = None
for attempt in range(1, MAX_RETRIES + 1):
try:
resp = SESSION.get(url, params=params, timeout=10)
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
last_err = e
if attempt < MAX_RETRIES:
time.sleep(BACKOFF_BASE_SECONDS * attempt)
continue
raise
if resp.status_code == 429 or resp.status_code >= 500:
last_err = requests.exceptions.HTTPError(f"HTTP {resp.status_code}", response=resp)
if attempt < MAX_RETRIES:
time.sleep(BACKOFF_BASE_SECONDS * attempt)
continue
raise last_err
resp.raise_for_status() # other 4xx: not retryable, raises immediately
try:
payload = resp.json()
except ValueError as e: # includes requests' JSONDecodeError
last_err = e
if attempt < MAX_RETRIES:
time.sleep(BACKOFF_BASE_SECONDS * attempt)
continue
raise
rows = payload.get("data") if isinstance(payload, dict) else None
if isinstance(rows, list):
return rows[0] if rows and isinstance(rows[0], dict) else {}
return rows if isinstance(rows, dict) else {}
raise last_err if last_err else RuntimeError("unreachable")
def raw_diagnostics(ticker: dict) -> dict:
"""Small whitelist of the RAW ticker fields needed to work out what
volume/size fields actually mean (audit C-1): timestamp, last price,
best bid/ask PRICE and size (Bitget has no historical ticker/order-book
API, so spread can only be reconstructed if it is recorded as we go)
and anything that looks like volume/turnover/amount. Public market
data only."""
keep = {"ts", "lastPrice", "bid1Price", "ask1Price", "bid1Size", "ask1Size"}
out = {}
for key, value in ticker.items():
low = str(key).lower()
if key in keep or any(t in low for t in ("vol", "turnover", "amount", "quote", "base")):
out[str(key)] = value if isinstance(value, (str, int, float)) or value is None else str(value)
return out
def _finite_positive(value: float | None) -> bool:
return value is not None and math.isfinite(value) and value > 0
def _finite_non_negative(value: float | None) -> bool:
return value is not None and math.isfinite(value) and value >= 0
def _parse_source_timestamp(raw_ts) -> datetime:
"""Parse Bitget's millisecond epoch timestamp into an aware UTC datetime."""
try:
ts_ms = int(raw_ts)
except (TypeError, ValueError) as exc:
raise ValueError(f"invalid Bitget ticker timestamp: {raw_ts!r}") from exc
return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc)
def calculate_spread_pct(bid: float | None, ask: float | None) -> float | None:
"""Spread from bid/ask, as a percentage of mid price."""
if not _finite_positive(bid) or not _finite_positive(ask) or ask < bid:
return None
mid = (bid + ask) / 2
if not math.isfinite(mid) or mid <= 0:
return None
spread = (ask - bid) / mid * 100
return round(spread, 4) if math.isfinite(spread) else None
def validate_ticker(ticker: dict) -> tuple[dict, datetime]:
"""Validate the complete executable market-data boundary before scoring."""
if not isinstance(ticker, dict):
raise ValueError("ticker response is not an object")
source_dt = _parse_source_timestamp(ticker.get("ts"))
age_seconds = (datetime.now(timezone.utc) - source_dt).total_seconds()
if age_seconds < -60:
raise ValueError(f"Bitget ticker timestamp is too far in the future: {age_seconds:.1f}s")
if age_seconds > MAX_MARKET_DATA_AGE_SECONDS:
raise ValueError(f"stale Bitget ticker: {age_seconds:.0f}s old (max {MAX_MARKET_DATA_AGE_SECONDS}s)")
def number(field: str, positive: bool = False, allow_none: bool = False):
raw = ticker.get(field)
if raw in (None, "") and allow_none:
return None
try:
value = float(raw)
except (TypeError, ValueError) as exc:
raise ValueError(f"invalid numeric field {field}: {raw!r}") from exc
valid = _finite_positive(value) if positive else _finite_non_negative(value)
if not valid:
raise ValueError(f"invalid numeric field {field}: {value!r}")
return value
price = number("lastPrice", positive=True)
bid = number("bid1Price", positive=True)
ask = number("ask1Price", positive=True)
bid_size = number("bid1Size", allow_none=True)
ask_size = number("ask1Size", allow_none=True)
volume = number("volume24h", allow_none=True)
if ask < bid:
raise ValueError(f"invalid order book: ask {ask} < bid {bid}")
return {
"price": price, "bid": bid, "ask": ask,
"bid_size": bid_size, "ask_size": ask_size,
"volume_24h": volume,
"spread_pct": calculate_spread_pct(bid, ask),
"source_timestamp": source_dt.isoformat(),
"data_age_seconds": round(max(0.0, age_seconds), 3),
}, source_dt
def fetch_bitget_data() -> list[dict]:
"""
Main entry point - called from the central data puller.
Returns a list of dicts, one entry per rToken.
"""
results = []
for underlying, symbol in SYMBOLS.items():
try:
ticker = fetch_ticker(symbol)
if not ticker:
raise ValueError("empty ticker response - symbol may be wrong or not listed")
market, source_dt = validate_ticker(ticker)
results.append({
"underlying": underlying,
"symbol": symbol,
**market,
"timestamp": datetime.now(timezone.utc).isoformat(),
"source": "bitget_api_v3",
"status": "ok",
"raw_diagnostics": raw_diagnostics(ticker),
})
except Exception as e:
results.append({
"underlying": underlying,
"symbol": symbol,
"timestamp": datetime.now(timezone.utc).isoformat(),
"source": "bitget_api_v3",
"status": "error",
"error": str(e),
})
return results
if __name__ == "__main__":
import json
print(json.dumps(fetch_bitget_data(), indent=2))