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.
- crypto_terminal/__init__.py +0 -0
- crypto_terminal/__main__.py +9 -0
- crypto_terminal/alerts.py +68 -0
- crypto_terminal/app.py +369 -0
- crypto_terminal/feed.py +98 -0
- crypto_terminal/history.py +113 -0
- crypto_terminal/indicators.py +44 -0
- crypto_terminal/widgets.py +228 -0
- crypto_terminal-2.0.0.dist-info/METADATA +128 -0
- crypto_terminal-2.0.0.dist-info/RECORD +13 -0
- crypto_terminal-2.0.0.dist-info/WHEEL +4 -0
- crypto_terminal-2.0.0.dist-info/entry_points.txt +2 -0
- crypto_terminal-2.0.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
|
@@ -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()
|
crypto_terminal/feed.py
ADDED
|
@@ -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
|
+

|
|
26
|
+
|
|
27
|
+
 
|
|
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,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.
|