hyperliquid-cli-python 0.1.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.
@@ -0,0 +1,399 @@
1
+ import contextlib
2
+ import json
3
+ import select
4
+ import sys
5
+ import termios
6
+ import time
7
+ import tty
8
+ from itertools import cycle
9
+ from typing import Any, Callable, Literal, Optional
10
+
11
+ from rich.console import Console
12
+ from rich.live import Live
13
+ from rich.panel import Panel
14
+
15
+ from ..utils.market_table import (
16
+ build_market_table,
17
+ market_table_columns,
18
+ market_table_row_values,
19
+ market_table_widths,
20
+ )
21
+
22
+
23
+ MarketsRows = dict[str, list[dict[str, Any]]]
24
+
25
+
26
+ def _market_row_dex(row: dict[str, Any]) -> str:
27
+ if row.get("marketType") == "spot":
28
+ return ""
29
+ coin = str(row.get("coin", ""))
30
+ if ":" in coin:
31
+ return coin.split(":", 1)[0]
32
+ return ""
33
+
34
+
35
+ def _market_row_kind(row: dict[str, Any]) -> Literal["perp", "spot"]:
36
+ return "spot" if row.get("marketType") == "spot" else "perp"
37
+
38
+
39
+ class MarketsTuiState:
40
+ def __init__(self) -> None:
41
+ self.scope: Literal["all", "perp", "spot"] = "all"
42
+ self.selected = 0
43
+ self.scroll = 0
44
+ self.mode: Literal["normal", "search"] = "normal"
45
+ self.search_direction: Literal["forward", "backward"] = "forward"
46
+ self.search_query = ""
47
+ self.search_buffer = ""
48
+
49
+ def rows(self, rows: MarketsRows) -> list[dict[str, Any]]:
50
+ merged = [*rows["perpMarkets"], *rows["spotMarkets"]]
51
+ if self.scope == "all":
52
+ return merged
53
+ return [row for row in merged if _market_row_kind(row) == self.scope]
54
+
55
+ def clamp(self, total: int, window_size: int) -> None:
56
+ if total <= 0:
57
+ self.selected = 0
58
+ self.scroll = 0
59
+ return
60
+ self.selected = max(0, min(self.selected, total - 1))
61
+ max_scroll = max(0, total - window_size)
62
+ if self.selected < self.scroll:
63
+ self.scroll = self.selected
64
+ elif self.selected >= self.scroll + window_size:
65
+ self.scroll = self.selected - window_size + 1
66
+ self.scroll = max(0, min(self.scroll, max_scroll))
67
+
68
+
69
+ @contextlib.contextmanager
70
+ def _raw_tty_mode() -> Any:
71
+ if not sys.stdin.isatty():
72
+ yield False
73
+ return
74
+ fd = sys.stdin.fileno()
75
+ old = termios.tcgetattr(fd)
76
+ try:
77
+ tty.setcbreak(fd)
78
+ yield True
79
+ finally:
80
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
81
+
82
+
83
+ def _read_key(timeout: float = 0.0) -> Optional[str]:
84
+ if not sys.stdin.isatty():
85
+ return None
86
+ ready, _, _ = select.select([sys.stdin], [], [], timeout)
87
+ if not ready:
88
+ return None
89
+ first = sys.stdin.read(1)
90
+ if first != "\x1b":
91
+ return first
92
+ ready, _, _ = select.select([sys.stdin], [], [], 0.01)
93
+ if not ready:
94
+ return first
95
+ second = sys.stdin.read(1)
96
+ if second != "[":
97
+ return first + second
98
+ ready, _, _ = select.select([sys.stdin], [], [], 0.01)
99
+ if not ready:
100
+ return first + second
101
+ third = sys.stdin.read(1)
102
+ return first + second + third
103
+
104
+
105
+ def _matches_query(row: dict[str, Any], query: str) -> bool:
106
+ needle = query.strip().lower()
107
+ if not needle:
108
+ return False
109
+ haystacks = [
110
+ str(row.get("coin", "")),
111
+ str(row.get("pairName", "")),
112
+ str(row.get("category", "")),
113
+ ]
114
+ return any(needle in value.lower() for value in haystacks)
115
+
116
+
117
+ def _find_match(rows: list[dict[str, Any]], start: int, query: str, *, forward: bool) -> Optional[int]:
118
+ if not rows or not query.strip():
119
+ return None
120
+ total = len(rows)
121
+ step = 1 if forward else -1
122
+ index = start
123
+ for _ in range(total):
124
+ index = (index + step) % total
125
+ if _matches_query(rows[index], query):
126
+ return index
127
+ return None
128
+
129
+
130
+ def _jump_to_match(
131
+ state: MarketsTuiState,
132
+ rows: MarketsRows,
133
+ *,
134
+ forward: bool,
135
+ wrap_from_current: bool,
136
+ window_size: int,
137
+ ) -> None:
138
+ current_rows = state.rows(rows)
139
+ if not current_rows or not state.search_query.strip():
140
+ return
141
+ start = state.selected - 1 if forward and not wrap_from_current else state.selected
142
+ if not forward and not wrap_from_current:
143
+ start = state.selected + 1
144
+ match = _find_match(current_rows, start, state.search_query, forward=forward)
145
+ if match is None:
146
+ return
147
+ state.selected = match
148
+ state.clamp(len(current_rows), window_size)
149
+
150
+
151
+ def _handle_key(key: Optional[str], state: MarketsTuiState, rows: MarketsRows, window_size: int) -> bool:
152
+ current_rows = state.rows(rows)
153
+ total = len(current_rows)
154
+ if state.mode == "search":
155
+ if key is None:
156
+ return False
157
+ if key in {"\r", "\n"}:
158
+ state.mode = "normal"
159
+ state.search_query = state.search_buffer
160
+ _jump_to_match(
161
+ state,
162
+ rows,
163
+ forward=state.search_direction == "forward",
164
+ wrap_from_current=False,
165
+ window_size=window_size,
166
+ )
167
+ return False
168
+ if key == "\x1b":
169
+ state.mode = "normal"
170
+ state.search_buffer = state.search_query
171
+ return False
172
+ if key in {"\x7f", "\b"}:
173
+ state.search_buffer = state.search_buffer[:-1]
174
+ return False
175
+ if len(key) == 1 and key.isprintable():
176
+ state.search_buffer += key
177
+ return False
178
+
179
+ if key in {"q", "\x03"}:
180
+ return True
181
+ if key in {"/", "?"}:
182
+ state.mode = "search"
183
+ state.search_direction = "forward" if key == "/" else "backward"
184
+ state.search_buffer = state.search_query
185
+ return False
186
+ if key in {"j", "\x1b[B"}:
187
+ state.selected += 1
188
+ elif key in {"k", "\x1b[A"}:
189
+ state.selected -= 1
190
+ elif key == "J":
191
+ state.selected += 10
192
+ elif key == "K":
193
+ state.selected -= 10
194
+ elif key == "g":
195
+ state.selected = 0
196
+ elif key == "G":
197
+ state.selected = max(0, total - 1)
198
+ elif key == "n":
199
+ _jump_to_match(
200
+ state,
201
+ rows,
202
+ forward=state.search_direction == "forward",
203
+ wrap_from_current=True,
204
+ window_size=window_size,
205
+ )
206
+ return False
207
+ elif key == "N":
208
+ _jump_to_match(
209
+ state,
210
+ rows,
211
+ forward=state.search_direction != "forward",
212
+ wrap_from_current=True,
213
+ window_size=window_size,
214
+ )
215
+ return False
216
+ elif key == "h":
217
+ state.scope = "perp"
218
+ state.selected = 0
219
+ state.scroll = 0
220
+ elif key == "l":
221
+ state.scope = "spot"
222
+ state.selected = 0
223
+ state.scroll = 0
224
+ elif key == "a":
225
+ state.scope = "all"
226
+ state.selected = 0
227
+ state.scroll = 0
228
+ state.clamp(len(state.rows(rows)), window_size)
229
+ return False
230
+
231
+
232
+ def _render_table(
233
+ rows: MarketsRows,
234
+ include_category: bool,
235
+ *,
236
+ console: Console,
237
+ state: MarketsTuiState,
238
+ widths_by_scope: dict[str, list[int]],
239
+ format_price: Callable[[Any], str],
240
+ format_usd: Callable[[Any], str],
241
+ format_rate_pct: Callable[[Any], str],
242
+ ) -> Panel:
243
+ current_rows = state.rows(rows)
244
+ # Leave room for the panel border, title/subtitle, and terminal prompt line.
245
+ window_size = max(5, console.size.height - 8)
246
+ state.clamp(len(current_rows), window_size)
247
+ visible_rows = current_rows[state.scroll : state.scroll + window_size]
248
+ selected_index = state.selected - state.scroll
249
+
250
+ show_perp_only_fields = state.scope != "spot"
251
+ columns = market_table_columns(
252
+ include_category=include_category,
253
+ show_perp_only_fields=show_perp_only_fields,
254
+ )
255
+ rendered_rows = [
256
+ market_table_row_values(
257
+ row,
258
+ include_category=include_category,
259
+ show_perp_only_fields=show_perp_only_fields,
260
+ format_price=format_price,
261
+ format_usd=format_usd,
262
+ format_rate_pct=format_rate_pct,
263
+ )
264
+ for row in visible_rows
265
+ ]
266
+ table = build_market_table(
267
+ title=f"Markets ({len(rows['perpMarkets'])} perps, {len(rows['spotMarkets'])} spot)",
268
+ columns=columns,
269
+ rendered_rows=rendered_rows,
270
+ widths=widths_by_scope[state.scope],
271
+ highlighted_index=selected_index,
272
+ )
273
+
274
+ if state.mode == "search":
275
+ prefix = "/" if state.search_direction == "forward" else "?"
276
+ help_text = f"{prefix}{state.search_buffer}"
277
+ else:
278
+ help_text = (
279
+ f"scope={state.scope} rows={len(current_rows)} "
280
+ f"search={state.search_query or '-'} "
281
+ "hjkl/arrows move gg/G top/bottom / ? search n/N next/prev h perp l spot a all q quit"
282
+ )
283
+ return Panel(table, subtitle=help_text)
284
+
285
+
286
+ def run_markets_tui(
287
+ *,
288
+ console: Console,
289
+ rows: MarketsRows,
290
+ include_category: bool,
291
+ next_mids: Callable[[str], dict[str, Any]],
292
+ sort_rows: Callable[[MarketsRows], MarketsRows],
293
+ prepare_output: Callable[[MarketsRows], MarketsRows],
294
+ format_price: Callable[[Any], str],
295
+ format_usd: Callable[[Any], str],
296
+ format_rate_pct: Callable[[Any], str],
297
+ as_json: bool,
298
+ ) -> None:
299
+ if as_json:
300
+ print(json.dumps(prepare_output(rows), ensure_ascii=False))
301
+
302
+ row_map = {row["coin"]: row for row in [*rows["perpMarkets"], *rows["spotMarkets"]]}
303
+ dexes = sorted({_market_row_dex(row) for row in row_map.values()}, key=lambda value: (value != "", value))
304
+ if not dexes:
305
+ return
306
+
307
+ def refresh_rows() -> MarketsRows:
308
+ return sort_rows(rows)
309
+
310
+ if as_json:
311
+ try:
312
+ for dex in cycle(dexes):
313
+ mids = next_mids(dex)
314
+ for coin, row in row_map.items():
315
+ if _market_row_dex(row) != dex:
316
+ continue
317
+ if coin in mids:
318
+ row["price"] = mids[coin]
319
+ print(json.dumps(prepare_output(refresh_rows()), ensure_ascii=False))
320
+ time.sleep(1.0)
321
+ except KeyboardInterrupt:
322
+ return
323
+ return
324
+
325
+ state = MarketsTuiState()
326
+ widths_by_scope: dict[str, list[int]] = {}
327
+ for scope in ("all", "perp", "spot"):
328
+ state.scope = scope
329
+ scope_rows = state.rows(rows)
330
+ show_perp_only_fields = scope != "spot"
331
+ columns = market_table_columns(
332
+ include_category=include_category,
333
+ show_perp_only_fields=show_perp_only_fields,
334
+ )
335
+ rendered_rows = [
336
+ market_table_row_values(
337
+ row,
338
+ include_category=include_category,
339
+ show_perp_only_fields=show_perp_only_fields,
340
+ format_price=format_price,
341
+ format_usd=format_usd,
342
+ format_rate_pct=format_rate_pct,
343
+ )
344
+ for row in scope_rows
345
+ ]
346
+ widths_by_scope[scope] = market_table_widths(columns, rendered_rows)
347
+ state.scope = "all"
348
+ try:
349
+ with _raw_tty_mode():
350
+ initial = _render_table(
351
+ rows,
352
+ include_category,
353
+ console=console,
354
+ state=state,
355
+ widths_by_scope=widths_by_scope,
356
+ format_price=format_price,
357
+ format_usd=format_usd,
358
+ format_rate_pct=format_rate_pct,
359
+ )
360
+ with Live(initial, console=console, refresh_per_second=8, screen=True) as live:
361
+ for dex in cycle(dexes):
362
+ if _handle_key(_read_key(0.0), state, rows, 24):
363
+ return
364
+ mids = next_mids(dex)
365
+ for coin, row in row_map.items():
366
+ if _market_row_dex(row) != dex:
367
+ continue
368
+ if coin in mids:
369
+ row["price"] = mids[coin]
370
+ live.update(
371
+ _render_table(
372
+ refresh_rows(),
373
+ include_category,
374
+ console=console,
375
+ state=state,
376
+ widths_by_scope=widths_by_scope,
377
+ format_price=format_price,
378
+ format_usd=format_usd,
379
+ format_rate_pct=format_rate_pct,
380
+ )
381
+ )
382
+ tick_end = time.time() + 1.0
383
+ while time.time() < tick_end:
384
+ if _handle_key(_read_key(0.1), state, rows, 24):
385
+ return
386
+ live.update(
387
+ _render_table(
388
+ refresh_rows(),
389
+ include_category,
390
+ console=console,
391
+ state=state,
392
+ widths_by_scope=widths_by_scope,
393
+ format_price=format_price,
394
+ format_usd=format_usd,
395
+ format_rate_pct=format_rate_pct,
396
+ )
397
+ )
398
+ except KeyboardInterrupt:
399
+ return
hl_cli/cli/runtime.py ADDED
@@ -0,0 +1,82 @@
1
+ import asyncio
2
+ from concurrent.futures import Future
3
+ from functools import wraps
4
+ import threading
5
+ import time
6
+ from typing import Any, Awaitable, Callable, ParamSpec, TypeVar
7
+
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from ..core.context import CLIContext
12
+ from ..utils.output import out_error
13
+
14
+ console = Console()
15
+ P = ParamSpec("P")
16
+ R = TypeVar("R")
17
+
18
+
19
+ def cli_context(ctx: Any) -> CLIContext:
20
+ return ctx.obj["context"]
21
+
22
+
23
+ def json_output_enabled(ctx: Any) -> bool:
24
+ return bool(ctx.obj["json"])
25
+
26
+
27
+ def finish_command(ctx: Any) -> None:
28
+ if not json_output_enabled(ctx):
29
+ elapsed = time.perf_counter() - float(ctx.obj["start"])
30
+ print(f"\nExecution time: {elapsed:.2f}s")
31
+
32
+
33
+ def confirm(message: str, default: bool = False) -> bool:
34
+ suffix = "[Y/n]" if default else "[y/N]"
35
+ answer = input(f"{message} {suffix}: ").strip().lower()
36
+ if not answer:
37
+ return default
38
+ return answer in {"y", "yes"}
39
+
40
+
41
+ def render_table(title: str, columns: list[str], rows: list[list[Any]]) -> None:
42
+ table = Table(title=title)
43
+ for c in columns:
44
+ table.add_column(c)
45
+ for row in rows:
46
+ table.add_row(*[str(v) for v in row])
47
+ console.print(table)
48
+
49
+
50
+ def run_blocking(coro: Awaitable[R]) -> R:
51
+ try:
52
+ asyncio.get_running_loop()
53
+ except RuntimeError:
54
+ return asyncio.run(coro)
55
+
56
+ result: Future[R] = Future()
57
+
58
+ def runner() -> None:
59
+ try:
60
+ value = asyncio.run(coro)
61
+ except BaseException as exc: # noqa: BLE001
62
+ result.set_exception(exc)
63
+ return
64
+ result.set_result(value)
65
+
66
+ thread = threading.Thread(target=runner, daemon=True)
67
+ thread.start()
68
+ return result.result()
69
+
70
+
71
+ def cli_command(fn: Callable[P, R]) -> Callable[P, R]:
72
+ @wraps(fn)
73
+ def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
74
+ try:
75
+ return fn(*args, **kwargs)
76
+ except SystemExit:
77
+ raise
78
+ except Exception as exc: # noqa: BLE001
79
+ out_error(str(exc))
80
+ raise SystemExit(1) from exc
81
+
82
+ return wrapper
@@ -0,0 +1 @@
1
+ """Command implementations."""