44Connects to Binance public WebSocket, no API key needed.
55"""
66
7+ import argparse
78import asyncio
89import json
910import curses
10- import sys
1111from collections import deque
1212from datetime import datetime
1313
1414import websockets
1515
1616BINANCE_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
1826CANDLE_SECONDS = 10 # change to 60 for 1-min candles
1927MAX_CANDLES = 80
@@ -52,8 +60,9 @@ def body_bot(self):
5260
5361
5462class 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+
296427async 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
318455def 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