crypto-terminal 2.0.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.
File without changes
@@ -0,0 +1,9 @@
1
+ from .app import TerminalApp
2
+
3
+
4
+ def main():
5
+ TerminalApp().run()
6
+
7
+
8
+ if __name__ == "__main__":
9
+ main()
@@ -0,0 +1,68 @@
1
+ """Price alerts: fire once when a pair reaches a level or moves a percentage from where the alert was set."""
2
+
3
+ import math
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from dataclasses import dataclass
8
+
9
+ from .history import SYMBOL_RE
10
+ from .widgets import fmt_price
11
+
12
+ KINDS = (">", "<", "move")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Alert:
17
+ symbol: str
18
+ kind: str # one of KINDS
19
+ level: float # price for > and <, percent for move
20
+ ref: float | None = None # move only: the price when the alert was set
21
+
22
+ def __str__(self):
23
+ if self.kind == "move":
24
+ return f"{self.symbol} moves ±{self.level:g}% from {fmt_price(self.ref)}"
25
+ return f"{self.symbol} {self.kind} {fmt_price(self.level)}"
26
+
27
+
28
+ def valid_level(kind: str, level: float) -> bool:
29
+ # chained comparisons reject NaN, inf, zero and negatives
30
+ return 0 < level < (100 if kind == "move" else math.inf)
31
+
32
+
33
+ def valid_alert(alert: Alert) -> bool:
34
+ """Full check for alerts read back from disk (the file is user-editable)."""
35
+ return (isinstance(alert.symbol, str) and bool(SYMBOL_RE.fullmatch(alert.symbol)) and alert.kind in KINDS
36
+ and valid_level(alert.kind, alert.level)
37
+ and (alert.kind != "move" or (alert.ref is not None and 0 < alert.ref < math.inf)))
38
+
39
+
40
+ def triggered(alert: Alert, price: float) -> bool:
41
+ if alert.kind == ">":
42
+ return price >= alert.level
43
+ if alert.kind == "<":
44
+ return price <= alert.level
45
+ return abs(price - alert.ref) / alert.ref * 100 >= alert.level
46
+
47
+
48
+ def check(alerts: list[Alert], symbol: str, price: float) -> tuple[list[Alert], list[Alert]]:
49
+ """Split alerts into (fired, remaining) for one tick."""
50
+ fired = [a for a in alerts if a.symbol == symbol and triggered(a, price)]
51
+ return fired, [a for a in alerts if a not in fired]
52
+
53
+
54
+ def desktop_notify(title: str, message: str):
55
+ """Best effort: osascript on macOS, notify-send on Linux, silently skipped if neither exists."""
56
+ if sys.platform == "darwin" and shutil.which("osascript"):
57
+ # text goes in as argv, never spliced into the script, so it can't inject AppleScript
58
+ args = ["osascript", "-e", "on run argv", "-e",
59
+ "display notification (item 2 of argv) with title (item 1 of argv)",
60
+ "-e", "end run", title, message]
61
+ elif shutil.which("notify-send"):
62
+ args = ["notify-send", "--", title, message]
63
+ else:
64
+ return
65
+ try:
66
+ subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
67
+ except OSError:
68
+ pass
crypto_terminal/app.py ADDED
@@ -0,0 +1,369 @@
1
+ import json
2
+ import math
3
+ import os
4
+ import re
5
+ import time
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ import httpx
10
+ from textual import work
11
+ from textual.app import App, ComposeResult
12
+ from textual.containers import Horizontal
13
+ from textual.widgets import Input, Static
14
+
15
+ from .alerts import KINDS, Alert, desktop_notify, valid_alert, valid_level
16
+ from .alerts import check as check_alerts
17
+ from .feed import Feed, Tick
18
+ from .history import GRANULARITIES, SYMBOL_RE, fetch_candles, fetch_products, product_exists, search_products
19
+ from .widgets import INDICATORS, ChartPane, PairPicker, PriceTable, fmt_price
20
+
21
+ CONFIG_DIR = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config") / "crypto-terminal"
22
+ WATCHLIST_FILE = CONFIG_DIR / "watchlist.json"
23
+ CONFIG_FILE = CONFIG_DIR / "config.json"
24
+ ALERTS_FILE = CONFIG_DIR / "alerts.json"
25
+ DEFAULT_WATCHLIST = ["BTC-USD", "ETH-USD", "XRP-USD", "SOL-USD", "ADA-USD"]
26
+ QUERY_RE = re.compile(r"[A-Z0-9]{1,20}") # coin search: `add sol`, `add solana`
27
+ STALE_AFTER = 10 # seconds without any feed frame before the status line shows STALE
28
+
29
+
30
+ # ── Watchlist, config + commands ──────────────────────────────────────────────
31
+
32
+ def write_json(path: Path, data):
33
+ path.parent.mkdir(parents=True, exist_ok=True)
34
+ path.write_text(json.dumps(data, indent=2))
35
+
36
+
37
+ def load_watchlist() -> list[str]:
38
+ try:
39
+ data = json.loads(WATCHLIST_FILE.read_text())
40
+ except (OSError, ValueError):
41
+ return list(DEFAULT_WATCHLIST)
42
+ symbols = data.get("symbols") if isinstance(data, dict) else None
43
+ if not isinstance(symbols, list):
44
+ return list(DEFAULT_WATCHLIST)
45
+ # file is user-editable: keep only well-formed pairs, drop duplicates
46
+ return list(dict.fromkeys(s for s in symbols if isinstance(s, str) and SYMBOL_RE.fullmatch(s)))
47
+
48
+
49
+ def save_watchlist(symbols: list[str]):
50
+ write_json(WATCHLIST_FILE, {"symbols": symbols})
51
+
52
+
53
+ def load_indicators() -> list[str]:
54
+ try:
55
+ data = json.loads(CONFIG_FILE.read_text())
56
+ except (OSError, ValueError):
57
+ return []
58
+ names = data.get("indicators") if isinstance(data, dict) else None
59
+ return [n for n in INDICATORS if isinstance(names, list) and n in names]
60
+
61
+
62
+ def save_indicators(names):
63
+ write_json(CONFIG_FILE, {"indicators": [n for n in INDICATORS if n in names]})
64
+
65
+
66
+ def load_alerts() -> list[Alert]:
67
+ try:
68
+ data = json.loads(ALERTS_FILE.read_text())
69
+ except (OSError, ValueError):
70
+ return []
71
+ out = []
72
+ for a in data if isinstance(data, list) else []:
73
+ try:
74
+ alert = Alert(a["symbol"], a["kind"], float(a["level"]), None if a.get("ref") is None else float(a["ref"]))
75
+ except (TypeError, KeyError, ValueError, AttributeError):
76
+ continue
77
+ if valid_alert(alert):
78
+ out.append(alert)
79
+ return out
80
+
81
+
82
+ def save_alerts(alerts: list[Alert]):
83
+ write_json(ALERTS_FILE, [vars(a) for a in alerts])
84
+
85
+
86
+ ALERT_USAGE = "usage: alert BTC-USD > 90000 · alert BTC-USD < 80000 · alert BTC-USD move 5%"
87
+
88
+
89
+ def parse_command(text: str) -> tuple[str, str | list[str] | Alert | int | None]:
90
+ """`add SOL-USD` -> ("add", "SOL-USD"), `add sol` -> ("find", "SOL"), `ind rsi` -> ("ind", ["rsi"]),
91
+ `alert BTC-USD > 90000` -> ("alert", Alert(...)), `unalert 2` -> ("unalert", 2), `quit` -> ("quit", None).
92
+
93
+ Raises ValueError with a user-facing message.
94
+ """
95
+ parts = text.split()
96
+ if not parts:
97
+ raise ValueError("empty command")
98
+ cmd, args = parts[0].lower(), parts[1:]
99
+ if cmd == "quit" and not args:
100
+ return "quit", None
101
+ if cmd in ("add", "rm"):
102
+ if len(args) != 1:
103
+ raise ValueError(f"usage: {cmd} BASE-QUOTE, e.g. {cmd} SOL-USD")
104
+ symbol = args[0].upper()
105
+ if SYMBOL_RE.fullmatch(symbol):
106
+ return cmd, symbol
107
+ if cmd == "add" and QUERY_RE.fullmatch(symbol):
108
+ return "find", symbol
109
+ raise ValueError(f"not a pair: {args[0]} (expected e.g. SOL-USD{', or a coin: add solana' if cmd == 'add' else ''})")
110
+ if cmd == "ind":
111
+ names = [a.lower() for a in args]
112
+ if names == ["off"]:
113
+ return "ind", []
114
+ if not names or any(n not in INDICATORS for n in names):
115
+ raise ValueError(f"usage: ind {' '.join(INDICATORS)} (toggles) or ind off")
116
+ return "ind", names
117
+ if cmd == "alert":
118
+ if len(args) != 3 or not SYMBOL_RE.fullmatch(args[0].upper()) or args[1].lower() not in KINDS:
119
+ raise ValueError(ALERT_USAGE)
120
+ kind = args[1].lower()
121
+ try:
122
+ level = float(args[2].removesuffix("%") if kind == "move" else args[2])
123
+ except ValueError:
124
+ level = math.nan
125
+ if not valid_level(kind, level):
126
+ raise ValueError("move must be between 0 and 100%" if kind == "move" else "price must be a positive number")
127
+ return "alert", Alert(args[0].upper(), kind, level)
128
+ if cmd == "alerts" and not args:
129
+ return "alerts", None
130
+ if cmd == "unalert":
131
+ try:
132
+ index = int(args[0]) if len(args) == 1 else 0
133
+ except ValueError:
134
+ index = 0
135
+ if index < 1:
136
+ raise ValueError("usage: unalert N (numbers from `alerts`)")
137
+ return "unalert", index
138
+ raise ValueError(f"unknown command: {text.strip()} (try add / rm / ind / alert / alerts / unalert / quit)")
139
+
140
+
141
+ # ── App ───────────────────────────────────────────────────────────────────────
142
+
143
+ class TerminalApp(App):
144
+ TITLE = "Crypto Terminal"
145
+ CSS = """
146
+ Screen { background: black; }
147
+ #title { height: 1; padding: 0 1; background: #ffb000; color: black; text-style: bold; }
148
+ #main { height: 1fr; }
149
+ PriceTable { width: 65; height: 1fr; background: black; border-right: solid #ffb000; }
150
+ PriceTable > .datatable--header { background: black; color: #ffb000; text-style: bold; }
151
+ PriceTable > .datatable--cursor { background: #3a2a00; }
152
+ #cmd { display: none; background: black; border: solid #ffb000; }
153
+ #status { height: 1; padding: 0 1; background: #1c1c1c; color: #ffb000; }
154
+ """
155
+ BINDINGS = [
156
+ ("slash", "command", "Command"),
157
+ ("colon", "command", "Command"),
158
+ ("escape", "close_command", "Close command bar"),
159
+ *((key, f"timeframe('{key}')", label) for key, (label, _) in GRANULARITIES.items()),
160
+ ]
161
+
162
+ def compose(self) -> ComposeResult:
163
+ yield Static("CRYPTO TERMINAL · COINBASE SPOT · [1-6] 1m 5m 15m 1h 6h 1d · [/] command", id="title", markup=False)
164
+ with Horizontal(id="main"):
165
+ yield PriceTable(id="watchlist")
166
+ yield ChartPane(id="chart")
167
+ yield Input(placeholder="add SOL-USD · add solana · rm ADA-USD · ind sma20 ema50 vwap rsi · ind off · "
168
+ "alert BTC-USD > 90000 · alerts · unalert 1 · quit (esc closes)", id="cmd")
169
+ yield Static(id="status", markup=False) # status carries server text: never parse it as markup
170
+
171
+ def on_mount(self):
172
+ self.symbols = load_watchlist()
173
+ self.http = httpx.AsyncClient()
174
+ self.products = None # online Coinbase pairs, loaded once at startup for `add` search
175
+ self.feed: Feed | None = None
176
+ self.feed_status = "idle"
177
+ self.last_tick = 0.0
178
+ self.selected: str | None = None
179
+ self.timeframe = "1"
180
+ table = self.query_one(PriceTable)
181
+ table.set_symbols(self.symbols)
182
+ table.focus()
183
+ self.query_one(ChartPane).set_indicators(load_indicators())
184
+ self.alerts = load_alerts()
185
+ table.set_alerts({a.symbol for a in self.alerts})
186
+ self.restart_feed()
187
+ self.load_products()
188
+ self.set_interval(1, self.refresh_status)
189
+ self.refresh_status()
190
+
191
+ async def on_unmount(self):
192
+ await self.http.aclose()
193
+
194
+ # ── feed ──
195
+
196
+ def restart_feed(self):
197
+ if self.symbols:
198
+ self.run_feed() # exclusive worker: cancels the previous connection
199
+ else:
200
+ self.workers.cancel_group(self, "feed")
201
+ self.feed = None
202
+ self.handle_feed_status("idle")
203
+
204
+ @work(exclusive=True, group="feed")
205
+ async def run_feed(self):
206
+ self.feed = Feed(self.symbols, self.handle_tick, self.handle_feed_status)
207
+ await self.feed.run()
208
+
209
+ def handle_tick(self, tick: Tick):
210
+ self.last_tick = time.monotonic()
211
+ self.query_one(PriceTable).update_tick(tick)
212
+ if tick.symbol == self.selected:
213
+ self.query_one(ChartPane).update_price(tick.symbol, tick.price)
214
+ if self.alerts:
215
+ fired, self.alerts = check_alerts(self.alerts, tick.symbol, tick.price)
216
+ if fired:
217
+ self.alerts_changed()
218
+ self.bell()
219
+ for alert in fired:
220
+ message = f"{alert} · now {fmt_price(tick.price)}"
221
+ self.notify(message, title="🔔 alert", severity="error", timeout=15)
222
+ desktop_notify("crypto-terminal alert", message)
223
+
224
+ def handle_feed_status(self, status: str):
225
+ self.feed_status = status
226
+ self.refresh_status()
227
+
228
+ def refresh_status(self):
229
+ now = time.monotonic()
230
+ state = self.feed_status.upper()
231
+ if self.feed and self.feed_status == "live" and now - self.feed.last_msg > STALE_AFTER:
232
+ state = "STALE"
233
+ age = f"{now - self.last_tick:.0f}s ago" if self.last_tick else "—"
234
+ clock = datetime.now(timezone.utc).strftime("%H:%M:%S UTC")
235
+ self.query_one("#status", Static).update(f"● {state} │ last tick {age} │ {clock}")
236
+
237
+ @work(exclusive=True, group="products")
238
+ async def load_products(self):
239
+ try:
240
+ self.products = await fetch_products(self.http)
241
+ except (httpx.HTTPError, ValueError):
242
+ pass # `add` falls back to a per-pair lookup; search stays unavailable
243
+
244
+ # ── chart ──
245
+
246
+ def on_data_table_row_highlighted(self, event: PriceTable.RowHighlighted):
247
+ self.selected = event.row_key.value
248
+ self.load_chart()
249
+
250
+ def action_timeframe(self, key: str):
251
+ self.timeframe = key
252
+ self.load_chart()
253
+
254
+ @work(exclusive=True, group="chart")
255
+ async def load_chart(self):
256
+ # exclusive: a newer selection cancels this fetch, so a slow reply can't draw the wrong pair
257
+ chart = self.query_one(ChartPane)
258
+ if self.selected not in self.symbols:
259
+ chart.show_message("watchlist empty: press / then `add BTC-USD`")
260
+ return
261
+ symbol, (label, granularity) = self.selected, GRANULARITIES[self.timeframe]
262
+ chart.show_message(f"loading {symbol} {label}…")
263
+ try:
264
+ candles = await fetch_candles(self.http, symbol, granularity)
265
+ except httpx.HTTPError as e:
266
+ chart.show_message(f"{symbol} {label}: history unavailable ({type(e).__name__})")
267
+ return
268
+ chart.show(symbol, label, granularity, candles)
269
+
270
+ # ── command bar ──
271
+
272
+ def action_command(self):
273
+ cmd = self.query_one("#cmd", Input)
274
+ cmd.display = True
275
+ cmd.focus()
276
+
277
+ def action_close_command(self):
278
+ cmd = self.query_one("#cmd", Input)
279
+ cmd.value, cmd.display = "", False
280
+ self.query_one(PriceTable).focus()
281
+
282
+ def on_input_submitted(self, event: Input.Submitted):
283
+ text = event.value
284
+ self.action_close_command()
285
+ try:
286
+ cmd, arg = parse_command(text)
287
+ except ValueError as e:
288
+ self.notify(str(e), severity="error")
289
+ return
290
+ if cmd == "quit":
291
+ self.exit()
292
+ elif cmd == "add":
293
+ self.add_symbol(arg)
294
+ elif cmd == "find":
295
+ self.find_pair(arg)
296
+ elif cmd == "ind":
297
+ chart = self.query_one(ChartPane)
298
+ chart.set_indicators(chart.indicators ^ set(arg) if arg else ()) # toggle
299
+ save_indicators(chart.indicators)
300
+ elif cmd == "alert":
301
+ self.add_alert(arg)
302
+ elif cmd == "alerts":
303
+ self.notify("\n".join(f"{i}. {a}" for i, a in enumerate(self.alerts, 1)) or "no alerts", markup=False)
304
+ elif cmd == "unalert":
305
+ if arg > len(self.alerts):
306
+ self.notify(f"no alert {arg} (see `alerts`)", severity="error")
307
+ else:
308
+ self.notify(f"removed: {self.alerts.pop(arg - 1)}", markup=False)
309
+ self.alerts_changed()
310
+ elif arg not in self.symbols:
311
+ self.notify(f"{arg} is not on the watchlist", severity="error")
312
+ else:
313
+ self.symbols.remove(arg)
314
+ self.watchlist_changed()
315
+
316
+ @work(group="commands")
317
+ async def add_symbol(self, symbol: str):
318
+ if symbol in self.symbols:
319
+ self.notify(f"{symbol} is already on the watchlist")
320
+ return
321
+ try:
322
+ exists = (any(p.id == symbol for p in self.products) if self.products
323
+ else await product_exists(self.http, symbol))
324
+ except httpx.HTTPError as e:
325
+ self.notify(f"could not check {symbol} with Coinbase ({type(e).__name__})", severity="error")
326
+ return
327
+ if not exists:
328
+ self.notify(f"unknown pair: {symbol}", severity="error")
329
+ elif symbol not in self.symbols: # re-check: a second `add` may have landed while we awaited
330
+ self.symbols.append(symbol)
331
+ self.watchlist_changed()
332
+
333
+ def find_pair(self, query: str):
334
+ if not self.products:
335
+ self.notify("coin search unavailable (Coinbase product list not loaded): add an exact pair, e.g. add SOL-USD",
336
+ severity="error")
337
+ return
338
+ matches = search_products(self.products, query)
339
+ if not matches:
340
+ self.notify(f"no matches for {query}", severity="error")
341
+ else:
342
+ self.push_screen(PairPicker(query, matches), lambda symbol: symbol and self.add_symbol(symbol))
343
+
344
+ def add_alert(self, alert: Alert):
345
+ # ticks only arrive for watchlist pairs, so an alert anywhere else could never fire
346
+ if alert.symbol not in self.symbols:
347
+ self.notify(f"{alert.symbol} is not on the watchlist: add it first", severity="error")
348
+ return
349
+ if alert.kind == "move":
350
+ ref = self.query_one(PriceTable).last_price(alert.symbol)
351
+ if ref is None:
352
+ self.notify(f"no price for {alert.symbol} yet: try again in a moment", severity="error")
353
+ return
354
+ alert = Alert(alert.symbol, alert.kind, alert.level, ref)
355
+ self.alerts.append(alert)
356
+ self.alerts_changed()
357
+ self.notify(f"alert set: {alert}", markup=False)
358
+
359
+ def alerts_changed(self):
360
+ save_alerts(self.alerts)
361
+ self.query_one(PriceTable).set_alerts({a.symbol for a in self.alerts})
362
+
363
+ def watchlist_changed(self):
364
+ save_watchlist(self.symbols)
365
+ self.query_one(PriceTable).set_symbols(self.symbols)
366
+ self.query_one(PriceTable).set_alerts({a.symbol for a in self.alerts})
367
+ self.restart_feed()
368
+ if not self.symbols:
369
+ self.load_chart()
@@ -0,0 +1,98 @@
1
+ """Coinbase Exchange public websocket feed (ticker + heartbeat channels, no API key)."""
2
+
3
+ import asyncio
4
+ import json
5
+ import math
6
+ import random
7
+ import ssl
8
+ import time
9
+ from dataclasses import dataclass
10
+ from typing import Callable
11
+
12
+ import certifi
13
+ from websockets.asyncio.client import connect
14
+
15
+ FEED_URL = "wss://ws-feed.exchange.coinbase.com"
16
+ RECONNECT_AFTER = 30 # seconds of silence (heartbeats arrive every 1s) before the socket is presumed dead
17
+ MAX_BACKOFF = 30
18
+
19
+ # python.org macOS builds ship without a CA bundle; use certifi's so wss:// works everywhere
20
+ SSL_CONTEXT = ssl.create_default_context(cafile=certifi.where())
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Tick:
25
+ symbol: str
26
+ price: float
27
+ open_24h: float
28
+
29
+
30
+ def parse_tick(msg: dict) -> Tick | None:
31
+ """Validate a `ticker` message. Returns None for anything malformed."""
32
+ if msg.get("type") != "ticker":
33
+ return None
34
+ try:
35
+ symbol = msg["product_id"]
36
+ price = float(msg["price"])
37
+ open_24h = float(msg["open_24h"])
38
+ except (KeyError, TypeError, ValueError):
39
+ return None
40
+ # chained comparisons reject NaN, inf, zero and negatives
41
+ if not isinstance(symbol, str) or not (0 < price < math.inf and 0 < open_24h < math.inf):
42
+ return None
43
+ return Tick(symbol, price, open_24h)
44
+
45
+
46
+ class Feed:
47
+ """Streams ticks for `symbols` forever, reconnecting with exponential backoff.
48
+
49
+ `status` is one of: connecting, live, reconnecting in Ns (reason).
50
+ `last_msg` is the monotonic time of the last frame received (ticks or heartbeats).
51
+ """
52
+
53
+ def __init__(self, symbols: list[str], on_tick: Callable[[Tick], None],
54
+ on_status: Callable[[str], None] = lambda s: None, url: str = FEED_URL,
55
+ reconnect_after: float = RECONNECT_AFTER):
56
+ self.symbols = list(symbols)
57
+ self.on_tick = on_tick
58
+ self.on_status = on_status
59
+ self.url = url
60
+ self.reconnect_after = reconnect_after
61
+ self.last_msg = 0.0
62
+
63
+ async def run(self):
64
+ delay = 1
65
+ while True:
66
+ self.on_status("connecting")
67
+ try:
68
+ async with connect(self.url, ssl=SSL_CONTEXT if self.url.startswith("wss") else None,
69
+ open_timeout=10) as ws:
70
+ await ws.send(json.dumps({"type": "subscribe", "product_ids": self.symbols,
71
+ "channels": ["ticker", "heartbeat"]}))
72
+ live = False
73
+ while True:
74
+ # wait_for catches a half-open socket that never errors
75
+ raw = await asyncio.wait_for(ws.recv(), self.reconnect_after)
76
+ try:
77
+ msg = json.loads(raw)
78
+ except ValueError:
79
+ continue
80
+ if not isinstance(msg, dict):
81
+ continue
82
+ if msg.get("type") == "error":
83
+ raise RuntimeError(str(msg.get("reason") or msg.get("message")))
84
+ tick = parse_tick(msg)
85
+ if tick or msg.get("type") == "heartbeat":
86
+ self.last_msg = time.monotonic()
87
+ if not live:
88
+ # only data proves the link works, so backoff resets here, not on connect
89
+ live, delay = True, 1
90
+ self.on_status("live")
91
+ if tick:
92
+ self.on_tick(tick)
93
+ except asyncio.CancelledError:
94
+ raise
95
+ except Exception as e: # network errors, closes, timeouts, server errors: all mean reconnect
96
+ self.on_status(f"reconnecting in {delay}s ({str(e) or type(e).__name__})")
97
+ await asyncio.sleep(delay + random.uniform(0, delay / 4))
98
+ delay = min(delay * 2, MAX_BACKOFF)
@@ -0,0 +1,113 @@
1
+ """Coinbase Exchange REST: candle history and product lookup (public, no API key)."""
2
+
3
+ import math
4
+ import re
5
+ import time
6
+ from typing import NamedTuple
7
+
8
+ import httpx
9
+
10
+ REST_BASE = "https://api.exchange.coinbase.com"
11
+ SYMBOL_RE = re.compile(r"[A-Z0-9]{1,10}-[A-Z0-9]{2,10}")
12
+ QUOTE_RANK = ("USD", "USDC", "USDT", "EUR", "GBP", "BTC") # search lists these quotes first, in this order
13
+ MAX_CANDLES = 300 # Coinbase caps a request at 300 candles
14
+
15
+ # key -> (label, candle granularity in seconds): every granularity Coinbase offers
16
+ GRANULARITIES = {
17
+ "1": ("1m", 60),
18
+ "2": ("5m", 300),
19
+ "3": ("15m", 900),
20
+ "4": ("1h", 3600),
21
+ "5": ("6h", 21600),
22
+ "6": ("1d", 86400),
23
+ }
24
+
25
+
26
+ class Candle(NamedTuple):
27
+ t: float # bucket start, unix seconds
28
+ o: float
29
+ h: float
30
+ l: float
31
+ c: float
32
+ v: float
33
+
34
+
35
+ class Product(NamedTuple):
36
+ id: str
37
+ base: str
38
+ quote: str
39
+ name: str # base coin's full name ("Solana"), "" if unknown
40
+
41
+
42
+ async def fetch_products(client: httpx.AsyncClient) -> list[Product]:
43
+ """Every online pair, with coin names from /currencies when available. Raises httpx.HTTPError / ValueError."""
44
+ resp = await client.get(f"{REST_BASE}/products", timeout=10)
45
+ resp.raise_for_status()
46
+ rows = resp.json()
47
+ names = {}
48
+ try: # names are a nicety: search by symbol still works without them
49
+ cur = await client.get(f"{REST_BASE}/currencies", timeout=10)
50
+ cur.raise_for_status()
51
+ currencies = cur.json()
52
+ names = {c["id"]: c["name"] for c in currencies if isinstance(c, dict)
53
+ and isinstance(c.get("id"), str) and isinstance(c.get("name"), str)} if isinstance(currencies, list) else {}
54
+ except (httpx.HTTPError, ValueError):
55
+ pass
56
+ out = []
57
+ for row in rows if isinstance(rows, list) else []:
58
+ if (isinstance(row, dict) and row.get("status") == "online" and row.get("trading_disabled") is False
59
+ and isinstance(row.get("id"), str) and SYMBOL_RE.fullmatch(row["id"])):
60
+ base, quote = row["id"].split("-")
61
+ out.append(Product(row["id"], base, quote, names.get(base, "")))
62
+ return out
63
+
64
+
65
+ def search_products(products: list[Product], query: str) -> list[Product]:
66
+ """Pairs whose coin symbol or name matches `query` (exact before prefix), best-known quote currencies first."""
67
+ q = query.upper()
68
+
69
+ def rank(p: Product):
70
+ exact = q in (p.base, p.name.upper())
71
+ quote = QUOTE_RANK.index(p.quote) if p.quote in QUOTE_RANK else len(QUOTE_RANK)
72
+ return not exact, quote, p.id
73
+
74
+ return sorted((p for p in products if p.base.startswith(q) or p.name.upper().startswith(q)), key=rank)
75
+
76
+
77
+ async def fetch_candles(client: httpx.AsyncClient, symbol: str, granularity: int) -> list[Candle]:
78
+ """Return the last MAX_CANDLES candles, oldest first. Raises httpx.HTTPError on network/HTTP failure."""
79
+ end = int(time.time())
80
+ resp = await client.get(f"{REST_BASE}/products/{symbol}/candles", params={
81
+ "granularity": granularity, "start": end - MAX_CANDLES * granularity, "end": end,
82
+ }, timeout=10)
83
+ resp.raise_for_status()
84
+ # rows are [time, low, high, open, close, volume], newest first; skip anything malformed
85
+ rows = resp.json()
86
+ out = []
87
+ for row in rows if isinstance(rows, list) else []:
88
+ try:
89
+ t, low, high, open_, close, volume = map(float, row[:6])
90
+ except (TypeError, ValueError, KeyError):
91
+ continue
92
+ if all(map(math.isfinite, (t, low, high, open_, close, volume))):
93
+ out.append(Candle(t, open_, high, low, close, volume))
94
+ return sorted(out)
95
+
96
+
97
+ def apply_tick(candles: list[Candle], price: float, now: float, granularity: int):
98
+ """Fold a live price into the last candle, or roll over to a new candle once `now` leaves its bucket."""
99
+ bucket = now - now % granularity
100
+ last = candles[-1]
101
+ if bucket <= last.t:
102
+ candles[-1] = last._replace(h=max(last.h, price), l=min(last.l, price), c=price)
103
+ else:
104
+ candles.append(Candle(bucket, price, price, price, price, 0.0))
105
+ del candles[0]
106
+
107
+
108
+ async def product_exists(client: httpx.AsyncClient, symbol: str) -> bool:
109
+ resp = await client.get(f"{REST_BASE}/products/{symbol}", timeout=10)
110
+ if resp.status_code in (400, 404):
111
+ return False
112
+ resp.raise_for_status()
113
+ return True
@@ -0,0 +1,44 @@
1
+ """Chart indicators as pure functions. Each returns a list aligned with its input, None until there is enough data."""
2
+
3
+ DAY = 86400
4
+
5
+
6
+ def sma(values: list[float], n: int) -> list[float | None]:
7
+ return [None] * min(n - 1, len(values)) + [sum(values[i - n:i]) / n for i in range(n, len(values) + 1)]
8
+
9
+
10
+ def ema(values: list[float], n: int) -> list[float | None]:
11
+ """Seeded with the SMA of the first n values, then smoothed with k = 2 / (n + 1)."""
12
+ if len(values) < n:
13
+ return [None] * len(values)
14
+ out, k = [None] * (n - 1) + [sum(values[:n]) / n], 2 / (n + 1)
15
+ for v in values[n:]:
16
+ out.append(v * k + out[-1] * (1 - k))
17
+ return out
18
+
19
+
20
+ def rsi(closes: list[float], n: int = 14) -> list[float | None]:
21
+ """Wilder's RSI: average gain / loss over n changes, then Wilder smoothing."""
22
+ out = [None] * len(closes)
23
+ if len(closes) <= n:
24
+ return out
25
+ deltas = [b - a for a, b in zip(closes, closes[1:])]
26
+ gain = sum(max(d, 0) for d in deltas[:n]) / n
27
+ loss = sum(max(-d, 0) for d in deltas[:n]) / n
28
+ for i in range(n, len(closes)):
29
+ if i > n:
30
+ d = deltas[i - 1]
31
+ gain, loss = (gain * (n - 1) + max(d, 0)) / n, (loss * (n - 1) + max(-d, 0)) / n
32
+ out[i] = 100.0 if loss == 0 else 100 - 100 / (1 + gain / loss)
33
+ return out
34
+
35
+
36
+ def vwap(candles) -> list[float | None]:
37
+ """Volume-weighted typical price over candles (t, h, l, c, v), reset at each UTC day boundary."""
38
+ out, day, pv, vol = [], None, 0.0, 0.0
39
+ for c in candles:
40
+ if c.t // DAY != day:
41
+ day, pv, vol = c.t // DAY, 0.0, 0.0
42
+ pv, vol = pv + (c.h + c.l + c.c) / 3 * c.v, vol + c.v
43
+ out.append(pv / vol if vol else None)
44
+ return out
@@ -0,0 +1,228 @@
1
+ import math
2
+ import time
3
+ from collections import deque
4
+ from datetime import datetime, timezone
5
+
6
+ from rich.text import Text
7
+ from textual.screen import ModalScreen
8
+ from textual.widgets import DataTable, OptionList
9
+ from textual.widgets.option_list import Option
10
+ from textual_plotext import PlotextPlot
11
+
12
+ from .feed import Tick
13
+ from .history import Candle, Product, apply_tick
14
+ from .indicators import ema, rsi, sma, vwap
15
+
16
+ SPARK = "▁▂▃▄▅▆▇█"
17
+ SPARK_LEN = 20
18
+ SIG_FIGS = 5
19
+ FLASH_SECONDS = 0.5
20
+ UP, DOWN = "green", "red"
21
+ REDRAW_EVERY = 0.5 # live candle redraws at most twice a second
22
+ VOLUME_ROWS = 7
23
+ RSI_ROWS = 8
24
+
25
+ # name -> (legend, colour, series over all candles); RSI gets its own panel
26
+ OVERLAYS = {
27
+ "sma20": ("SMA 20", "yellow", lambda candles: sma([c.c for c in candles], 20)),
28
+ "ema50": ("EMA 50", "cyan", lambda candles: ema([c.c for c in candles], 50)),
29
+ "vwap": ("VWAP", "magenta", vwap),
30
+ }
31
+ INDICATORS = (*OVERLAYS, "rsi")
32
+
33
+
34
+ def fmt_price(value: float) -> str:
35
+ """2 decimals from 1,000 up, otherwise enough decimals for SIG_FIGS significant figures (0.0021300)."""
36
+ if value >= 1_000:
37
+ return f"{value:,.2f}"
38
+ decimals = max(2, SIG_FIGS - 1 - math.floor(math.log10(value))) if value > 0 else 2
39
+ return f"{value:.{decimals}f}"
40
+
41
+
42
+ def visible(times: list[float], series: list[float | None]) -> tuple[list, list]:
43
+ """Tail of `series` lined up with `times`, minus the None warm-up."""
44
+ pts = [(t, v) for t, v in zip(times, series[-len(times):]) if v is not None]
45
+ return [t for t, _ in pts], [v for _, v in pts]
46
+
47
+
48
+ def sparkline(values) -> str:
49
+ lo, hi = min(values), max(values)
50
+ if hi == lo:
51
+ return SPARK[0] * len(values)
52
+ return "".join(SPARK[round((v - lo) / (hi - lo) * (len(SPARK) - 1))] for v in values)
53
+
54
+
55
+ class PriceTable(DataTable):
56
+ """Watchlist: one row per pair, price flashes green/red on each up/down tick."""
57
+
58
+ def on_mount(self):
59
+ self.cursor_type = "row"
60
+ for label, key, width in (("PAIR", "sym", 13), ("LAST", "last", 12), ("24H %", "chg", 10),
61
+ ("TICKS", "spark", SPARK_LEN)):
62
+ self.add_column(label, key=key, width=width)
63
+ self._last: dict[str, Tick] = {}
64
+ self._dir: dict[str, str] = {}
65
+ self._spark: dict[str, deque] = {}
66
+ self._flashing: set[str] = set()
67
+
68
+ def set_symbols(self, symbols: list[str]):
69
+ for sym in list(self._spark):
70
+ if sym not in symbols:
71
+ self.remove_row(sym)
72
+ for d in (self._last, self._dir, self._spark):
73
+ d.pop(sym, None)
74
+ for sym in symbols:
75
+ if sym not in self._spark:
76
+ self._spark[sym] = deque(maxlen=SPARK_LEN)
77
+ self.add_row(Text(sym, style="bold"), "—", "—", "", key=sym)
78
+
79
+ def set_alerts(self, symbols: set[str]):
80
+ """Mark pairs that have an alert set."""
81
+ for sym in self._spark:
82
+ self.update_cell(sym, "sym", Text(f"{sym} 🔔" if sym in symbols else sym, style="bold"))
83
+
84
+ def last_price(self, sym: str) -> float | None:
85
+ return self._last[sym].price if sym in self._last else None
86
+
87
+ def update_tick(self, tick: Tick):
88
+ sym = tick.symbol
89
+ if sym not in self._spark: # late tick for a pair just removed
90
+ return
91
+ prev = self._last.get(sym)
92
+ self._last[sym] = tick
93
+ self._spark[sym].append(tick.price)
94
+ if prev and tick.price != prev.price:
95
+ self._dir[sym] = UP if tick.price > prev.price else DOWN
96
+ if sym not in self._flashing:
97
+ self._flashing.add(sym)
98
+ self.set_timer(FLASH_SECONDS, lambda: self._unflash(sym))
99
+ pct = (tick.price - tick.open_24h) / tick.open_24h * 100
100
+ self.update_cell(sym, "chg", Text(f"{'▲' if pct >= 0 else '▼'} {pct:+.2f}%", style=UP if pct >= 0 else DOWN))
101
+ self.update_cell(sym, "spark", Text(sparkline(self._spark[sym]), style="dark_orange"))
102
+ self._render_price(sym)
103
+
104
+ def _unflash(self, sym: str):
105
+ self._flashing.discard(sym)
106
+ if sym in self._last:
107
+ self._render_price(sym)
108
+
109
+ def _render_price(self, sym: str):
110
+ color = self._dir.get(sym, "white")
111
+ style = f"bold black on {color}" if sym in self._flashing else f"bold {color}"
112
+ self.update_cell(sym, "last", Text(fmt_price(self._last[sym].price), style=style, justify="right"))
113
+
114
+
115
+ class PairPicker(ModalScreen[str | None]):
116
+ """Search results for `add <coin>`: enter adds the highlighted pair, esc cancels."""
117
+
118
+ BINDINGS = [("escape", "dismiss", "Cancel")]
119
+ DEFAULT_CSS = """
120
+ PairPicker { align: center middle; }
121
+ PairPicker > OptionList { width: 50; height: auto; max-height: 20; background: black; border: solid #ffb000; }
122
+ """
123
+
124
+ def __init__(self, query: str, products: list[Product]):
125
+ super().__init__()
126
+ self.term, self.products = query, products
127
+
128
+ def compose(self):
129
+ # Text, not str: coin names are server data and must never be parsed as markup
130
+ picker = OptionList(*(Option(Text(f"{p.id:<14}{p.name}"), id=p.id) for p in self.products))
131
+ picker.border_title = f"add {self.term.lower()} · enter adds · esc cancels"
132
+ yield picker
133
+
134
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected):
135
+ self.dismiss(event.option.id)
136
+
137
+
138
+ class ChartPane(PlotextPlot):
139
+ """Candles for one pair; the last candle follows live ticks via `update_price`."""
140
+
141
+ def on_mount(self):
142
+ self.theme = "dark"
143
+ self.symbol, self.candles, self.indicators, self._dirty = None, [], set(), False
144
+ self.set_interval(REDRAW_EVERY, self._redraw_if_dirty)
145
+ self.show_message("select a pair")
146
+
147
+ def show_message(self, text: str):
148
+ self.candles = [] # stop live updates until the next `show`
149
+ self.plt.clear_figure()
150
+ self.plt.title(text)
151
+ self.refresh()
152
+
153
+ def show(self, symbol: str, label: str, granularity: int, candles: list[Candle]):
154
+ if not candles:
155
+ return self.show_message(f"{symbol} {label}: no data")
156
+ self.symbol, self.label, self.granularity, self.candles = symbol, label, granularity, candles
157
+ self._draw()
158
+
159
+ def set_indicators(self, names):
160
+ self.indicators = set(names)
161
+ self._dirty = bool(self.candles)
162
+
163
+ def update_price(self, symbol: str, price: float):
164
+ if self.candles and symbol == self.symbol:
165
+ apply_tick(self.candles, price, time.time(), self.granularity)
166
+ self._dirty = True
167
+
168
+ def on_resize(self):
169
+ self._dirty = bool(self.candles) # visible candle count depends on width
170
+
171
+ def _redraw_if_dirty(self):
172
+ if self._dirty and self.candles:
173
+ self._draw()
174
+
175
+ def _draw(self):
176
+ self._dirty = False
177
+ # one terminal column per candle: draw the tail that fits; indicators see the full history
178
+ shown = self.candles[-max(10, self.size.width - 12):]
179
+ times = [c.t for c in shown]
180
+ lines = [(legend, color, visible(times, series(self.candles)))
181
+ for name, (legend, color, series) in OVERLAYS.items() if name in self.indicators]
182
+ show_rsi = "rsi" in self.indicators
183
+ panels = 3 if show_rsi else 2
184
+ plt = self.plt
185
+ plt.clear_figure()
186
+ plt.subplots(panels, 1)
187
+
188
+ top = plt.subplot(1, 1)
189
+ top.candlestick(times, {"Open": [c.o for c in shown], "Close": [c.c for c in shown],
190
+ "High": [c.h for c in shown], "Low": [c.l for c in shown]})
191
+ for legend, color, (xs, ys) in lines:
192
+ if xs:
193
+ top.plot(xs, ys, color=color, marker="braille", label=legend)
194
+ top.title(f"{self.symbol} · {self.label} · {fmt_price(shown[-1].c)} (UTC)")
195
+ # our own y labels, padded to one width, so every panel's x axis lines up under the candles
196
+ overlay_values = [y for _, _, (_, ys) in lines for y in ys]
197
+ lo, hi = min([c.l for c in shown] + overlay_values), max([c.h for c in shown] + overlay_values)
198
+ ys = [lo + (hi - lo) * i / 4 for i in range(5)]
199
+ width = max(len(fmt_price(y)) for y in ys)
200
+ top.yticks(ys, [fmt_price(y).rjust(width) for y in ys])
201
+
202
+ vol = plt.subplot(2, 1)
203
+ vol.plotsize(None, VOLUME_ROWS)
204
+ vmax = max(c.v for c in shown)
205
+ vol.bar(times, [c.v for c in shown], color="blue", width=0.5)
206
+ vol.yticks([0, vmax], ["0".rjust(width), fmt_price(vmax).rjust(width)])
207
+
208
+ if show_rsi:
209
+ panel = plt.subplot(3, 1)
210
+ panel.plotsize(None, RSI_ROWS)
211
+ xs, ys = visible(times, rsi([c.c for c in self.candles]))
212
+ if xs:
213
+ panel.plot(xs, ys, color="orange", marker="braille", label="RSI 14")
214
+ panel.hline(70, "gray")
215
+ panel.hline(30, "gray")
216
+ panel.ylim(0, 100)
217
+ panel.yticks([30, 70], ["30".rjust(width), "70".rjust(width)])
218
+
219
+ fmt = "%d/%m" if self.granularity >= 3600 else "%H:%M"
220
+ ticks = times[:: max(1, len(times) // 5)]
221
+ for row in range(1, panels + 1):
222
+ panel = plt.subplot(row, 1)
223
+ panel.xlim(times[0], times[-1])
224
+ if row < panels:
225
+ panel.xticks([])
226
+ # numeric x + our own tick labels: plotext's date parsing loses the day for "H:M" and wraps at midnight
227
+ panel.xticks(ticks, [datetime.fromtimestamp(t, timezone.utc).strftime(fmt) for t in ticks])
228
+ self.refresh()
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.5
2
+ Name: crypto-terminal
3
+ Version: 2.0.0
4
+ Summary: A free, Bloomberg-style crypto terminal for your shell: live Coinbase prices, candles, indicators and alerts.
5
+ Project-URL: Homepage, https://github.com/owen-alderson/crypto-terminal
6
+ Author: Owen Alderson
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: bitcoin,coinbase,crypto,terminal,textual,tui
10
+ Classifier: Environment :: Console
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Office/Business :: Financial :: Investment
13
+ Requires-Python: >=3.10
14
+ Requires-Dist: certifi
15
+ Requires-Dist: httpx>=0.27
16
+ Requires-Dist: textual-plotext>=1.0
17
+ Requires-Dist: textual>=8.0
18
+ Requires-Dist: websockets>=14.0
19
+ Description-Content-Type: text/markdown
20
+
21
+ # crypto-terminal
22
+
23
+ A free, Bloomberg-style crypto terminal that runs in your shell. Live spot prices stream straight from the Coinbase Exchange public websocket: no API key, no account, no polling. Candlestick charts with a live last bar, indicators, price alerts, and search across every Coinbase pair.
24
+
25
+ ![Crypto terminal demo: live candles, indicators, coin search and a price alert](https://raw.githubusercontent.com/owen-alderson/crypto-terminal/main/docs/demo.gif)
26
+
27
+ ![Textual](https://img.shields.io/badge/built%20with-Textual-ffb000) ![Coinbase](https://img.shields.io/badge/data-Coinbase%20Exchange-0052FF)
28
+
29
+ ## Install
30
+
31
+ Requires Python 3.10+.
32
+
33
+ ```bash
34
+ pipx install crypto-terminal
35
+ crypto-terminal
36
+ ```
37
+
38
+ Or `pip install crypto-terminal` into any virtualenv.
39
+
40
+ ## Features
41
+
42
+ - **Live watchlist**: last price, 24h change, and a sparkline of recent ticks. Prices flash green/red on every up/down tick.
43
+ - **Candlestick chart** for the highlighted pair, with volume underneath. Six timeframes (1m to 1d candles). The last candle follows the live price and a new one opens at each candle boundary.
44
+ - **Indicators**: SMA 20, EMA 50 and VWAP (resets at the UTC day) over the candles, RSI 14 in its own panel.
45
+ - **Coin search**: `add solana` lists every online Solana pair (USD, USDC, USDT, EUR, GBP and BTC quotes first). Exact pairs like `add SOL-BTC` add directly. Sub-cent and BTC-quoted prices keep 5 significant figures.
46
+ - **Price alerts**: on a level (`>` / `<`) or a percentage move. A firing alert rings the terminal bell, shows a toast, and sends a desktop notification (macOS, or Linux with `notify-send`). Pairs with an alert carry a 🔔.
47
+ - **Status line**: connection state, time since the last tick, UTC clock. Shows `STALE` after 10s without data and reconnects automatically with exponential backoff.
48
+
49
+ Default pairs: BTC-USD, ETH-USD, XRP-USD, SOL-USD, ADA-USD.
50
+
51
+ ## How live is it?
52
+
53
+ Measured on the five default pairs over 60s:
54
+
55
+ | | Quiet (Sunday) | Busy |
56
+ |---|---|---|
57
+ | Price updates | ~5.5 / s | ~12 / s |
58
+ | Bandwidth | ~0.02 Mbps | ~0.04 Mbps |
59
+
60
+ Coinbase pushes an update on every trade (bursts are batched); nothing is polled. A heartbeat arrives every second, so a dead connection is spotted and replaced within seconds. The chart redraws at most twice a second.
61
+
62
+ ## Keys
63
+
64
+ | Key | Action |
65
+ |---|---|
66
+ | `↑` `↓` | Select pair (chart follows) |
67
+ | `1` – `6` | Candles: 1m, 5m, 15m, 1h, 6h, 1d |
68
+ | `/` or `:` | Open command bar (`esc` closes it) |
69
+ | `ctrl+q` | Quit |
70
+
71
+ ## Commands
72
+
73
+ | Command | Effect |
74
+ |---|---|
75
+ | `add SOL-USD` | Add a pair |
76
+ | `add sol` / `add solana` | Search by symbol or name: pick with `↑` `↓`, `enter` adds, `esc` cancels |
77
+ | `rm ADA-USD` | Remove a pair |
78
+ | `ind sma20 ema50 vwap rsi` | Toggle indicators (any subset) |
79
+ | `ind off` | Clear all indicators |
80
+ | `alert BTC-USD > 90000` | Alert when the price reaches 90,000 or more (`<` for at or below) |
81
+ | `alert BTC-USD move 5%` | Alert on a ±5% move from the current price |
82
+ | `alerts` | List alerts with their numbers |
83
+ | `unalert 2` | Remove alert 2 |
84
+ | `quit` | Exit |
85
+
86
+ Alerts fire once and are then removed. The watchlist, indicators and alerts are saved in `~/.config/crypto-terminal/` (or `$XDG_CONFIG_HOME/crypto-terminal/`).
87
+
88
+ ## From source
89
+
90
+ ```bash
91
+ git clone https://github.com/owen-alderson/crypto-terminal.git
92
+ cd crypto-terminal
93
+ python3 -m venv .venv
94
+ source .venv/bin/activate
95
+ pip install -r requirements.txt
96
+ python -m crypto_terminal
97
+ ```
98
+
99
+ ## Architecture
100
+
101
+ ```
102
+ crypto_terminal/
103
+ feed.py websocket client: subscribes to ticker + heartbeat, validates each message,
104
+ reconnects with exponential backoff (1s → 30s); 30s of silence = dead socket
105
+ history.py REST: candles, the product list for search, the live-candle update
106
+ indicators.py SMA, EMA, RSI, VWAP as pure functions
107
+ alerts.py alert rules, trigger check, desktop notification
108
+ widgets.py PriceTable (watchlist), ChartPane (textual-plotext), PairPicker (search results)
109
+ app.py Textual app: layout, command parsing, saved config, status line
110
+ tests/ pytest: parsing, indicators against reference values, search, alerts, reconnect
111
+ against a local websocket server, headless UI tests with Textual's Pilot (no internet)
112
+ ```
113
+
114
+ The feed, the chart fetch and the product list run as Textual async workers. Changing the watchlist restarts the feed worker, and a new chart request cancels the previous one so a slow response can't draw the wrong pair.
115
+
116
+ ## Tests
117
+
118
+ ```bash
119
+ pytest
120
+ ```
121
+
122
+ ## History
123
+
124
+ This repo started as a Streamlit + CoinGecko portfolio dashboard that polled every 60s. That version is preserved at the `v1-streamlit` tag (`git checkout v1-streamlit`).
125
+
126
+ ## License
127
+
128
+ MIT, see [LICENSE](LICENSE). Provided as is, with no warranty: this is a market-data viewer, not financial advice.
@@ -0,0 +1,13 @@
1
+ crypto_terminal/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ crypto_terminal/__main__.py,sha256=5S4_8qGX_RL0Tkm7RoUP6UCOGJfXmjGrjYEj4chKKvI,107
3
+ crypto_terminal/alerts.py,sha256=6P80M6dadOfA_uBewoDPPFrG4wwIK66-Rt-iXv0YZEQ,2575
4
+ crypto_terminal/app.py,sha256=kFYdDBE_mMO5uoH4GPyzwud-9uK3gHgj-TZX1o1VfCA,15323
5
+ crypto_terminal/feed.py,sha256=OtFsS9fjVzBemVOnTYsF9VXWsLogvRF-0JkVOkloezM,3970
6
+ crypto_terminal/history.py,sha256=nbru2pUhrIivdzGVJzaUNvpBvgNWonswd5ZFhW86WIs,4343
7
+ crypto_terminal/indicators.py,sha256=yVqOi_3u0qCNlaIPLfGZ2he1V5FyHtreopeLsrtx5II,1723
8
+ crypto_terminal/widgets.py,sha256=f6InTBNGy24HdhR3stdCknbkpDPmS1epAFWzkEQGvMw,9490
9
+ crypto_terminal-2.0.0.dist-info/METADATA,sha256=7vLECWmNQIkKt2M59NEUIhSYRi3YwM52nO6HhTOyM7U,5562
10
+ crypto_terminal-2.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ crypto_terminal-2.0.0.dist-info/entry_points.txt,sha256=9LeREDCHNMkHRIspVvAd5ZxXeXkjoeiOpt38AxMMjZI,66
12
+ crypto_terminal-2.0.0.dist-info/licenses/LICENSE,sha256=NWajlPPoczBhh6BjoC0u7pKkymgP3ZCbpGv3dnCZ1L8,1070
13
+ crypto_terminal-2.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ crypto-terminal = crypto_terminal.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Owen Alderson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.