Skip to content

Commit 33746f8

Browse files
authored
Merge pull request #2 from bscript/order-book
Add new exchanges
2 parents 59380fc + 9ce5c08 commit 33746f8

2 files changed

Lines changed: 182 additions & 24 deletions

File tree

README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# btc-tui
22

3-
A live BTC/USDT trade terminal in your terminal, real-time candlestick chart, trade feed, and buy/sell pressure bar. Powered by the Binance public WebSocket. No API key required.
3+
A live BTC trade terminal in your terminalreal-time candlestick chart, trade feed, and buy/sell pressure bar. Supports Binance, Coinbase, and Bybit. No API key required.
44

55
<img width="1324" height="856" alt="Image" src="https://github.com/user-attachments/assets/6828cce7-4b14-4bd7-8ed4-b6e28a665c60" />
66

@@ -39,19 +39,29 @@ pip install .
3939
## Usage
4040

4141
```sh
42-
btc-tui
42+
btc-tui # Binance (default)
43+
btc-tui --exchange coinbase # Coinbase
44+
btc-tui --exchange bybit # Bybit
4345
```
4446

4547
Press `q` or `Esc` to quit.
4648

47-
## Keys
49+
## Exchanges
50+
51+
| Flag | Exchange | Pair |
52+
| ---- | -------- | ---- |
53+
| *(default)* | Binance | BTC/USDT |
54+
| `--exchange coinbase` | Coinbase | BTC/USD |
55+
| `--exchange bybit` | Bybit | BTC/USDT |
4856

57+
All exchanges stream live trades and ticker data with no API key required.
58+
59+
## Keys
4960

5061
| Key | Action |
5162
| ----------------- | ------ |
5263
| `q` / `Q` / `Esc` | Quit |
5364

54-
5565
## Configuration
5666

5767
Edit `CANDLE_SECONDS` at the top of `btc_tui/main.py` to change the candle interval (default: `10` seconds, set to `60` for 1-minute candles).
@@ -60,7 +70,7 @@ Edit `CANDLE_SECONDS` at the top of `btc_tui/main.py` to change the candle inter
6070

6171
- Python 3.9+
6272
- A terminal at least 80×24 characters (bigger is better)
63-
- Internet connection (connects to `stream.binance.com`)
73+
- Internet connection
6474

6575
## License
6676

btc_tui/main.py

Lines changed: 167 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,24 @@
44
Connects to Binance public WebSocket, no API key needed.
55
"""
66

7+
import argparse
78
import asyncio
89
import json
910
import curses
10-
import sys
1111
from collections import deque
1212
from datetime import datetime
1313

1414
import websockets
1515

1616
BINANCE_WS = "wss://stream.binance.com:9443/stream?streams=btcusdt@aggTrade/btcusdt@ticker"
17+
COINBASE_WS = "wss://advanced-trade-ws.coinbase.com"
18+
BYBIT_WS = "wss://stream.bybit.com/v5/public/spot"
19+
20+
EXCHANGE_LABELS = {
21+
"binance": "Binance BTC/USDT",
22+
"coinbase": "Coinbase BTC/USD",
23+
"bybit": "Bybit BTC/USDT",
24+
}
1725

1826
CANDLE_SECONDS = 10 # change to 60 for 1-min candles
1927
MAX_CANDLES = 80
@@ -52,8 +60,9 @@ def body_bot(self):
5260

5361

5462
class TUI:
55-
def __init__(self, stdscr):
63+
def __init__(self, stdscr, exchange="binance"):
5664
self.stdscr = stdscr
65+
self.exchange = exchange
5766
self.trades = deque(maxlen=MAX_TRADES)
5867
self.ticker = {}
5968
self.candles = deque(maxlen=MAX_CANDLES)
@@ -80,12 +89,9 @@ def __init__(self, stdscr):
8089
def _bucket(self, ts_ms):
8190
return int(ts_ms / 1000 / CANDLE_SECONDS) * CANDLE_SECONDS
8291

83-
def ingest_trade(self, data):
84-
price = float(data["p"])
85-
qty = float(data["q"])
86-
is_buy = not data["m"]
87-
ts = self._bucket(data["T"])
88-
92+
def _ingest(self, price: float, qty: float, is_buy: bool, ts_ms: int):
93+
"""Common ingestion path used by all exchange adapters."""
94+
ts = self._bucket(ts_ms)
8995
self.prev_price = self.last_price or price
9096
self.last_price = price
9197

@@ -102,7 +108,87 @@ def ingest_trade(self, data):
102108
else:
103109
self.current_candle.update(price, qty, is_buy)
104110

105-
self.trades.append(data)
111+
# store as a normalised dict so the draw loop stays exchange-agnostic
112+
self.trades.append({"p": str(price), "q": str(qty), "m": not is_buy, "T": ts_ms})
113+
114+
def ingest_trade(self, data):
115+
"""Binance aggTrade adapter."""
116+
self._ingest(
117+
price=float(data["p"]),
118+
qty=float(data["q"]),
119+
is_buy=not data["m"],
120+
ts_ms=data["T"],
121+
)
122+
123+
# ── Coinbase adapters ────────────────────────────────────────────────────
124+
125+
def ingest_coinbase_trades(self, trades: list):
126+
for t in trades:
127+
try:
128+
ts_ms = int(datetime.fromisoformat(
129+
t["time"].replace("Z", "+00:00")
130+
).timestamp() * 1000)
131+
self._ingest(
132+
price=float(t["price"]),
133+
qty=float(t["size"]),
134+
is_buy=(t["side"] == "BUY"),
135+
ts_ms=ts_ms,
136+
)
137+
except (KeyError, ValueError):
138+
pass
139+
140+
def ingest_coinbase_ticker(self, tickers: list):
141+
for t in tickers:
142+
try:
143+
self.ticker = {
144+
"P": t.get("price_percent_chg_24_h", "0.00"),
145+
"h": t.get("high_24_h", "0"),
146+
"l": t.get("low_24_h", "0"),
147+
"v": t.get("volume_24_h", "0"),
148+
}
149+
except (KeyError, ValueError):
150+
pass
151+
152+
# ── Bybit adapters ───────────────────────────────────────────────────────
153+
154+
def ingest_bybit_trades(self, trades: list):
155+
for t in trades:
156+
try:
157+
self._ingest(
158+
price=float(t["p"]),
159+
qty=float(t["v"]),
160+
is_buy=(t["S"] == "Buy"),
161+
ts_ms=int(t["T"]),
162+
)
163+
except (KeyError, ValueError):
164+
pass
165+
166+
def ingest_bybit_ticker(self, data: dict):
167+
try:
168+
pct = float(data.get("price24hPcnt", 0)) * 100
169+
self.ticker = {
170+
"P": f"{pct:.2f}",
171+
"h": data.get("highPrice24h", "0"),
172+
"l": data.get("lowPrice24h", "0"),
173+
"v": data.get("volume24h", "0"),
174+
}
175+
except (KeyError, ValueError):
176+
pass
177+
178+
def handle_bybit_message(self, msg: dict):
179+
topic = msg.get("topic", "")
180+
if "publicTrade" in topic:
181+
self.ingest_bybit_trades(msg.get("data", []))
182+
elif "tickers" in topic:
183+
self.ingest_bybit_ticker(msg.get("data", {}))
184+
185+
def handle_coinbase_message(self, msg: dict):
186+
channel = msg.get("channel", "")
187+
for event in msg.get("events", []):
188+
if channel == "market_trades":
189+
self.ingest_coinbase_trades(event.get("trades", []))
190+
elif channel == "ticker":
191+
self.ingest_coinbase_ticker(event.get("tickers", []))
106192

107193
def _draw_candles(self, start_row, height, start_col, width):
108194
all_candles = list(self.candles) + ([self.current_candle] if self.current_candle else [])
@@ -185,7 +271,8 @@ def draw(self):
185271
now = datetime.now().strftime("%H:%M:%S")
186272
self.stdscr.attron(curses.color_pair(3) | curses.A_BOLD)
187273
self.stdscr.addstr(0, 0, "─" * (w - 1))
188-
title = f" ₿ BTC/USDT [{CANDLE_SECONDS}s candles] "
274+
label = EXCHANGE_LABELS.get(self.exchange, self.exchange)
275+
title = f" ₿ {label} [{CANDLE_SECONDS}s candles] "
189276
self.stdscr.addstr(0, 2, title)
190277
self.stdscr.addstr(0, w - len(now) - 3, now)
191278
self.stdscr.attroff(curses.color_pair(3) | curses.A_BOLD)
@@ -236,9 +323,14 @@ def draw(self):
236323
fc = split + 2
237324
fw = w - fc - 1
238325

239-
self.stdscr.addstr(div + 1, fc, "── TRADES ──"[:fw], curses.color_pair(3) | curses.A_BOLD)
240-
self.stdscr.addstr(div + 2, fc, f"{'TIME':8} {'S':1} {'PRICE':>10} {'QTY':>7}"[:fw],
241-
curses.color_pair(3))
326+
try:
327+
self.stdscr.addstr(div + 1, fc, "── TRADES ──"[:fw],
328+
curses.color_pair(3) | curses.A_BOLD)
329+
self.stdscr.addstr(div + 2, fc,
330+
f"{'TIME':8} {'S':1} {'PRICE':>10} {'QTY':>7}"[:fw],
331+
curses.color_pair(3))
332+
except curses.error:
333+
pass
242334

243335
feed_start = div + 3
244336
feed_rows = h - feed_start - 1
@@ -263,7 +355,7 @@ def draw(self):
263355
pass
264356

265357
try:
266-
footer = f" q quit │ {CANDLE_SECONDS}s candles │ Binance WebSocket "
358+
footer = f" q quit │ {CANDLE_SECONDS}s candles │ {EXCHANGE_LABELS.get(self.exchange, self.exchange)} "
267359
self.stdscr.addstr(h - 1, 0, footer[: w - 1], curses.color_pair(6))
268360
except curses.error:
269361
pass
@@ -279,7 +371,7 @@ def handle_message(self, msg):
279371
self.ticker = data
280372

281373

282-
async def ws_loop(tui):
374+
async def binance_ws_loop(tui):
283375
while tui.running:
284376
try:
285377
async with websockets.connect(BINANCE_WS, ping_interval=20) as ws:
@@ -293,6 +385,45 @@ async def ws_loop(tui):
293385
await asyncio.sleep(2)
294386

295387

388+
async def bybit_ws_loop(tui):
389+
while tui.running:
390+
try:
391+
async with websockets.connect(BYBIT_WS, ping_interval=20) as ws:
392+
await ws.send(json.dumps({
393+
"op": "subscribe",
394+
"args": ["publicTrade.BTCUSDT", "tickers.BTCUSDT"],
395+
}))
396+
while tui.running:
397+
try:
398+
raw = await asyncio.wait_for(ws.recv(), timeout=5)
399+
tui.handle_bybit_message(json.loads(raw))
400+
except asyncio.TimeoutError:
401+
pass
402+
except Exception:
403+
await asyncio.sleep(2)
404+
405+
406+
async def coinbase_ws_loop(tui):
407+
channels = ["market_trades", "ticker"]
408+
while tui.running:
409+
try:
410+
async with websockets.connect(COINBASE_WS, ping_interval=20) as ws:
411+
for ch in channels:
412+
await ws.send(json.dumps({
413+
"type": "subscribe",
414+
"product_ids": ["BTC-USD"],
415+
"channel": ch,
416+
}))
417+
while tui.running:
418+
try:
419+
raw = await asyncio.wait_for(ws.recv(), timeout=5)
420+
tui.handle_coinbase_message(json.loads(raw))
421+
except asyncio.TimeoutError:
422+
pass
423+
except Exception:
424+
await asyncio.sleep(2)
425+
426+
296427
async def input_loop(tui):
297428
while tui.running:
298429
try:
@@ -310,15 +441,32 @@ async def draw_loop(tui):
310441
await asyncio.sleep(0.1)
311442

312443

313-
async def _main(stdscr):
314-
tui = TUI(stdscr)
315-
await asyncio.gather(ws_loop(tui), draw_loop(tui), input_loop(tui))
444+
async def _main(stdscr, exchange: str):
445+
tui = TUI(stdscr, exchange=exchange)
446+
if exchange == "coinbase":
447+
loop = coinbase_ws_loop
448+
elif exchange == "bybit":
449+
loop = bybit_ws_loop
450+
else:
451+
loop = binance_ws_loop
452+
await asyncio.gather(loop(tui), draw_loop(tui), input_loop(tui))
316453

317454

318455
def run():
319456
"""Entry point for the btc-tui command."""
457+
parser = argparse.ArgumentParser(
458+
description="BTC live trade terminal",
459+
formatter_class=argparse.RawDescriptionHelpFormatter,
460+
)
461+
parser.add_argument(
462+
"--exchange",
463+
choices=["binance", "coinbase", "bybit"],
464+
default="binance",
465+
help="Data source (default: binance)",
466+
)
467+
args = parser.parse_args()
320468
try:
321-
curses.wrapper(lambda s: asyncio.run(_main(s)))
469+
curses.wrapper(lambda s: asyncio.run(_main(s, args.exchange)))
322470
except KeyboardInterrupt:
323471
pass
324472

0 commit comments

Comments
 (0)