regard-cli 0.2.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.
- hl_cli/__init__.py +0 -0
- hl_cli/client.py +98 -0
- hl_cli/commands/__init__.py +0 -0
- hl_cli/commands/act.py +254 -0
- hl_cli/commands/orient.py +174 -0
- hl_cli/commands/research.py +287 -0
- hl_cli/commands/review.py +57 -0
- hl_cli/main.py +55 -0
- hl_cli/output.py +110 -0
- hl_cli/resolver.py +145 -0
- regard_cli-0.2.0.dist-info/METADATA +80 -0
- regard_cli-0.2.0.dist-info/RECORD +15 -0
- regard_cli-0.2.0.dist-info/WHEEL +4 -0
- regard_cli-0.2.0.dist-info/entry_points.txt +2 -0
- regard_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
hl_cli/__init__.py
ADDED
|
File without changes
|
hl_cli/client.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""SDK client factory — creates Info and Exchange instances."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
import eth_account
|
|
6
|
+
from hyperliquid.exchange import Exchange
|
|
7
|
+
from hyperliquid.info import Info
|
|
8
|
+
from hyperliquid.utils.constants import MAINNET_API_URL
|
|
9
|
+
|
|
10
|
+
from hl_cli.output import die
|
|
11
|
+
|
|
12
|
+
TESTNET_API_URL = "https://api.hyperliquid-testnet.xyz"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _base_url(testnet: bool) -> str:
|
|
16
|
+
return TESTNET_API_URL if testnet else MAINNET_API_URL
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_key():
|
|
20
|
+
key = os.environ.get("HYPERLIQUID_AGENT_KEY") or os.environ.get("HYPERLIQUID_PRIVATE_KEY")
|
|
21
|
+
if not key:
|
|
22
|
+
die(
|
|
23
|
+
"auth_error", "NO_KEY",
|
|
24
|
+
"No trading key set",
|
|
25
|
+
hint="Set HYPERLIQUID_AGENT_KEY (agent wallet) or HYPERLIQUID_PRIVATE_KEY (master wallet)",
|
|
26
|
+
exit_code=3,
|
|
27
|
+
)
|
|
28
|
+
return key
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_master_address(info: Info, agent_address: str) -> str:
|
|
32
|
+
"""Look up the master account for an agent wallet via userRole API."""
|
|
33
|
+
try:
|
|
34
|
+
result = info.post("/info", {"type": "userRole", "user": agent_address})
|
|
35
|
+
role = result.get("role")
|
|
36
|
+
if role == "agent":
|
|
37
|
+
return result["data"]["user"]
|
|
38
|
+
if role == "user":
|
|
39
|
+
return agent_address # key IS the master key
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
return agent_address # fallback: assume key is master
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_address(explicit: str = None, testnet: bool = False) -> str:
|
|
46
|
+
"""Get account address for queries.
|
|
47
|
+
|
|
48
|
+
Priority: explicit arg > HYPERLIQUID_ACCOUNT_ADDRESS > auto-resolve via userRole API > derived from key.
|
|
49
|
+
"""
|
|
50
|
+
if explicit:
|
|
51
|
+
return explicit
|
|
52
|
+
addr = os.environ.get("HYPERLIQUID_ACCOUNT_ADDRESS")
|
|
53
|
+
if addr:
|
|
54
|
+
return addr
|
|
55
|
+
key = os.environ.get("HYPERLIQUID_AGENT_KEY") or os.environ.get("HYPERLIQUID_PRIVATE_KEY")
|
|
56
|
+
if key:
|
|
57
|
+
agent_address = eth_account.Account.from_key(key).address
|
|
58
|
+
if os.environ.get("HYPERLIQUID_AGENT_KEY"):
|
|
59
|
+
info = Info(_base_url(testnet), skip_ws=True)
|
|
60
|
+
return _resolve_master_address(info, agent_address)
|
|
61
|
+
return agent_address
|
|
62
|
+
die(
|
|
63
|
+
"auth_error", "NO_ADDRESS",
|
|
64
|
+
"No address available",
|
|
65
|
+
hint="Set HYPERLIQUID_AGENT_KEY or use --address",
|
|
66
|
+
exit_code=3,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def get_info(testnet: bool = False) -> Info:
|
|
71
|
+
"""Create Info client (default perps only, dexes loaded lazily by resolver)."""
|
|
72
|
+
return Info(_base_url(testnet), skip_ws=True)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def get_exchange(testnet: bool = False) -> Exchange:
|
|
76
|
+
"""Create Exchange client.
|
|
77
|
+
|
|
78
|
+
Supports two modes:
|
|
79
|
+
- Agent mode: HYPERLIQUID_AGENT_KEY (signs) + HYPERLIQUID_ACCOUNT_ADDRESS (queries master account)
|
|
80
|
+
- Direct mode: HYPERLIQUID_PRIVATE_KEY (signs and queries same account)
|
|
81
|
+
"""
|
|
82
|
+
key = _get_key()
|
|
83
|
+
wallet = eth_account.Account.from_key(key)
|
|
84
|
+
account_address = get_address()
|
|
85
|
+
return Exchange(wallet, _base_url(testnet), account_address=account_address)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def get_all_mids(info: Info) -> dict:
|
|
89
|
+
"""Get all mid prices including HIP-3 deployer assets."""
|
|
90
|
+
mids = info.all_mids()
|
|
91
|
+
try:
|
|
92
|
+
dex_list = info.post("/info", {"type": "perpDexs"})
|
|
93
|
+
for dex_info in dex_list[1:]:
|
|
94
|
+
dex_mids = info.post("/info", {"type": "allMids", "dex": dex_info["name"]})
|
|
95
|
+
mids.update(dex_mids)
|
|
96
|
+
except Exception:
|
|
97
|
+
pass
|
|
98
|
+
return mids
|
|
File without changes
|
hl_cli/commands/act.py
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""Act commands — order, cancel, cancel-all, leverage."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Annotated, Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from hl_cli.client import get_address, get_exchange
|
|
9
|
+
from hl_cli.main import hl_app
|
|
10
|
+
from hl_cli.output import die, output
|
|
11
|
+
from hl_cli.resolver import get_resolver
|
|
12
|
+
|
|
13
|
+
SIDE_MAP = {"buy": True, "long": True, "sell": False, "short": False}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _parse_order_response(resp, asset: str, side_str: str, size: str):
|
|
17
|
+
"""Parse SDK order response into CLI output format."""
|
|
18
|
+
if resp.get("status") == "err":
|
|
19
|
+
die("venue_error", "ORDER_REJECTED", resp.get("response", str(resp)), exit_code=4)
|
|
20
|
+
|
|
21
|
+
data = resp.get("response", {}).get("data", {})
|
|
22
|
+
statuses = data.get("statuses", [])
|
|
23
|
+
if not statuses:
|
|
24
|
+
die("venue_error", "ORDER_REJECTED", "No status in response", exit_code=4)
|
|
25
|
+
|
|
26
|
+
st = statuses[0]
|
|
27
|
+
if "error" in st:
|
|
28
|
+
die("venue_error", "ORDER_REJECTED", st["error"], exit_code=4)
|
|
29
|
+
|
|
30
|
+
if "filled" in st:
|
|
31
|
+
filled = st["filled"]
|
|
32
|
+
return {
|
|
33
|
+
"status": "filled",
|
|
34
|
+
"oid": filled.get("oid", 0),
|
|
35
|
+
"asset": asset,
|
|
36
|
+
"side": side_str,
|
|
37
|
+
"size": filled.get("totalSz", size),
|
|
38
|
+
"avg_price": filled.get("avgPx", ""),
|
|
39
|
+
}
|
|
40
|
+
elif "resting" in st:
|
|
41
|
+
resting = st["resting"]
|
|
42
|
+
return {
|
|
43
|
+
"status": "resting",
|
|
44
|
+
"oid": resting.get("oid", 0),
|
|
45
|
+
"asset": asset,
|
|
46
|
+
"side": side_str,
|
|
47
|
+
"size": size,
|
|
48
|
+
}
|
|
49
|
+
else:
|
|
50
|
+
return {"status": "unknown", "raw": st}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@hl_app.command()
|
|
55
|
+
def order(
|
|
56
|
+
ctx: typer.Context,
|
|
57
|
+
asset: Annotated[str, typer.Argument(help="Asset name")],
|
|
58
|
+
side: Annotated[str, typer.Argument(help="buy/long or sell/short")],
|
|
59
|
+
size: Annotated[str, typer.Argument(help="Amount in base asset")],
|
|
60
|
+
price: Annotated[Optional[str], typer.Argument(help="Limit price")] = None,
|
|
61
|
+
market: Annotated[bool, typer.Option("--market", help="Market order (IOC)")] = False,
|
|
62
|
+
tif: Annotated[Optional[str], typer.Option("--tif", help="gtc|ioc|alo")] = None,
|
|
63
|
+
reduce_only: Annotated[bool, typer.Option("--reduce-only", help="Reduce only")] = False,
|
|
64
|
+
tp: Annotated[Optional[str], typer.Option("--tp", help="Take profit trigger price")] = None,
|
|
65
|
+
sl: Annotated[Optional[str], typer.Option("--sl", help="Stop loss trigger price")] = None,
|
|
66
|
+
cloid: Annotated[Optional[str], typer.Option("--cloid", help="Client order ID (hex)")] = None,
|
|
67
|
+
):
|
|
68
|
+
"""Place an order."""
|
|
69
|
+
opts = ctx.obj
|
|
70
|
+
|
|
71
|
+
side_lower = side.lower()
|
|
72
|
+
if side_lower not in SIDE_MAP:
|
|
73
|
+
die("validation_error", "INVALID_SIDE", f"Invalid side: {side}", hint="Use buy/long or sell/short", param="side")
|
|
74
|
+
|
|
75
|
+
is_buy = SIDE_MAP[side_lower]
|
|
76
|
+
sz = float(size)
|
|
77
|
+
|
|
78
|
+
exchange = get_exchange(opts["testnet"])
|
|
79
|
+
resolver = get_resolver(exchange.info)
|
|
80
|
+
resolved = resolver.resolve(asset)
|
|
81
|
+
|
|
82
|
+
from hyperliquid.utils.types import Cloid as SdkCloid
|
|
83
|
+
sdk_cloid = SdkCloid.from_str(cloid) if cloid else None
|
|
84
|
+
|
|
85
|
+
if market or price is None:
|
|
86
|
+
# Market order
|
|
87
|
+
resp = exchange.market_open(resolved, is_buy, sz, cloid=sdk_cloid)
|
|
88
|
+
result = _parse_order_response(resp, resolved, side_lower, size)
|
|
89
|
+
output(result, opts["fields"])
|
|
90
|
+
return
|
|
91
|
+
|
|
92
|
+
# Limit order
|
|
93
|
+
px = float(price)
|
|
94
|
+
tif_val = (tif or "gtc").capitalize()
|
|
95
|
+
if tif_val == "Gtc":
|
|
96
|
+
tif_val = "Gtc"
|
|
97
|
+
elif tif_val == "Ioc":
|
|
98
|
+
tif_val = "Ioc"
|
|
99
|
+
elif tif_val == "Alo":
|
|
100
|
+
tif_val = "Alo"
|
|
101
|
+
order_type = {"limit": {"tif": tif_val}}
|
|
102
|
+
|
|
103
|
+
if tp or sl:
|
|
104
|
+
# Parent + TP/SL as grouped order
|
|
105
|
+
order_requests = [
|
|
106
|
+
{
|
|
107
|
+
"coin": resolved,
|
|
108
|
+
"is_buy": is_buy,
|
|
109
|
+
"sz": sz,
|
|
110
|
+
"limit_px": px,
|
|
111
|
+
"order_type": order_type,
|
|
112
|
+
"reduce_only": reduce_only,
|
|
113
|
+
"cloid": sdk_cloid,
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
if tp:
|
|
117
|
+
tp_type = {"trigger": {"triggerPx": float(tp), "isMarket": True, "tpsl": "tp"}}
|
|
118
|
+
order_requests.append({
|
|
119
|
+
"coin": resolved,
|
|
120
|
+
"is_buy": not is_buy,
|
|
121
|
+
"sz": sz,
|
|
122
|
+
"limit_px": float(tp),
|
|
123
|
+
"order_type": tp_type,
|
|
124
|
+
"reduce_only": True,
|
|
125
|
+
})
|
|
126
|
+
if sl:
|
|
127
|
+
sl_type = {"trigger": {"triggerPx": float(sl), "isMarket": True, "tpsl": "sl"}}
|
|
128
|
+
order_requests.append({
|
|
129
|
+
"coin": resolved,
|
|
130
|
+
"is_buy": not is_buy,
|
|
131
|
+
"sz": sz,
|
|
132
|
+
"limit_px": float(sl),
|
|
133
|
+
"order_type": sl_type,
|
|
134
|
+
"reduce_only": True,
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
resp = exchange.bulk_orders(
|
|
138
|
+
[exchange._order_request_to_wire(r) for r in order_requests]
|
|
139
|
+
if hasattr(exchange, "_order_request_to_wire")
|
|
140
|
+
else order_requests,
|
|
141
|
+
grouping="normalTpsl",
|
|
142
|
+
)
|
|
143
|
+
result = _parse_order_response(resp, resolved, side_lower, size)
|
|
144
|
+
output(result, opts["fields"])
|
|
145
|
+
else:
|
|
146
|
+
resp = exchange.order(resolved, is_buy, sz, px, order_type, reduce_only, cloid=sdk_cloid)
|
|
147
|
+
result = _parse_order_response(resp, resolved, side_lower, size)
|
|
148
|
+
output(result, opts["fields"])
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@hl_app.command()
|
|
152
|
+
def cancel(
|
|
153
|
+
ctx: typer.Context,
|
|
154
|
+
first: Annotated[str, typer.Argument(help="OID or asset name")],
|
|
155
|
+
second: Annotated[Optional[str], typer.Argument(help="OID (if first is asset)")] = None,
|
|
156
|
+
cloid: Annotated[Optional[str], typer.Option("--cloid", help="Cancel by client order ID")] = None,
|
|
157
|
+
asset_opt: Annotated[Optional[str], typer.Option("--asset", help="Asset for cloid cancel")] = None,
|
|
158
|
+
):
|
|
159
|
+
"""Cancel a specific order."""
|
|
160
|
+
opts = ctx.obj
|
|
161
|
+
exchange = get_exchange(opts["testnet"])
|
|
162
|
+
|
|
163
|
+
if cloid:
|
|
164
|
+
# Cancel by client order ID
|
|
165
|
+
if not asset_opt:
|
|
166
|
+
die("validation_error", "MISSING_ARGUMENT", "Must specify --asset with --cloid", hint="regard hl cancel --cloid 0x... --asset BTC")
|
|
167
|
+
resolver = get_resolver(exchange.info)
|
|
168
|
+
resolved = resolver.resolve(asset_opt)
|
|
169
|
+
from hyperliquid.utils.types import Cloid as SdkCloid
|
|
170
|
+
resp = exchange.cancel_by_cloid(resolved, SdkCloid.from_str(cloid))
|
|
171
|
+
output({"status": "cancelled", "asset": resolved, "cloid": cloid}, opts["fields"])
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
if second is not None:
|
|
175
|
+
# hl cancel BTC 77738308
|
|
176
|
+
resolver = get_resolver(exchange.info)
|
|
177
|
+
resolved = resolver.resolve(first)
|
|
178
|
+
oid = int(second)
|
|
179
|
+
exchange.cancel(resolved, oid)
|
|
180
|
+
output({"status": "cancelled", "oid": oid, "asset": resolved}, opts["fields"])
|
|
181
|
+
else:
|
|
182
|
+
# hl cancel 77738308 — auto-lookup asset from open orders
|
|
183
|
+
oid = int(first)
|
|
184
|
+
addr = get_address()
|
|
185
|
+
open_orders = exchange.info.open_orders(addr)
|
|
186
|
+
coin = None
|
|
187
|
+
for o in open_orders:
|
|
188
|
+
if o["oid"] == oid:
|
|
189
|
+
coin = o["coin"]
|
|
190
|
+
break
|
|
191
|
+
if not coin:
|
|
192
|
+
die("validation_error", "ORDER_NOT_FOUND", f"Order {oid} not found in open orders", hint=f"Pass asset explicitly: regard hl cancel BTC {oid}")
|
|
193
|
+
exchange.cancel(coin, oid)
|
|
194
|
+
output({"status": "cancelled", "oid": oid, "asset": coin}, opts["fields"])
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@hl_app.command(name="cancel-all")
|
|
198
|
+
def cancel_all(
|
|
199
|
+
ctx: typer.Context,
|
|
200
|
+
asset: Annotated[Optional[str], typer.Argument(help="Cancel only this asset's orders")] = None,
|
|
201
|
+
):
|
|
202
|
+
"""Cancel all open orders."""
|
|
203
|
+
opts = ctx.obj
|
|
204
|
+
exchange = get_exchange(opts["testnet"])
|
|
205
|
+
addr = get_address()
|
|
206
|
+
|
|
207
|
+
open_orders = exchange.info.open_orders(addr)
|
|
208
|
+
|
|
209
|
+
if asset:
|
|
210
|
+
resolver = get_resolver(exchange.info)
|
|
211
|
+
resolved = resolver.resolve(asset)
|
|
212
|
+
open_orders = [o for o in open_orders if o["coin"] == resolved]
|
|
213
|
+
|
|
214
|
+
if not open_orders:
|
|
215
|
+
output({"cancelled": 0, "assets": []}, opts["fields"])
|
|
216
|
+
return
|
|
217
|
+
|
|
218
|
+
cancel_requests = [{"coin": o["coin"], "oid": o["oid"]} for o in open_orders]
|
|
219
|
+
exchange.bulk_cancel(cancel_requests)
|
|
220
|
+
|
|
221
|
+
assets_cancelled = sorted(set(o["coin"] for o in open_orders))
|
|
222
|
+
output({"cancelled": len(open_orders), "assets": assets_cancelled}, opts["fields"])
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
@hl_app.command()
|
|
226
|
+
def leverage(
|
|
227
|
+
ctx: typer.Context,
|
|
228
|
+
asset: Annotated[str, typer.Argument(help="Asset name")],
|
|
229
|
+
lev: Annotated[int, typer.Argument(help="Leverage (integer)")],
|
|
230
|
+
cross: Annotated[bool, typer.Option("--cross", help="Cross margin")] = False,
|
|
231
|
+
isolated: Annotated[bool, typer.Option("--isolated", help="Isolated margin")] = False,
|
|
232
|
+
):
|
|
233
|
+
"""Set leverage for an asset."""
|
|
234
|
+
opts = ctx.obj
|
|
235
|
+
|
|
236
|
+
if cross and isolated:
|
|
237
|
+
die("validation_error", "CONFLICTING_OPTIONS", "Cannot specify both --cross and --isolated")
|
|
238
|
+
|
|
239
|
+
exchange = get_exchange(opts["testnet"])
|
|
240
|
+
resolver = get_resolver(exchange.info)
|
|
241
|
+
resolved = resolver.resolve(asset)
|
|
242
|
+
|
|
243
|
+
is_cross = not isolated # default is cross
|
|
244
|
+
if is_cross and ":" in resolved:
|
|
245
|
+
print("Warning: HIP-3 assets support isolated margin only. Using isolated.", file=sys.stderr)
|
|
246
|
+
is_cross = False
|
|
247
|
+
|
|
248
|
+
exchange.update_leverage(lev, resolved, is_cross)
|
|
249
|
+
|
|
250
|
+
output({
|
|
251
|
+
"asset": resolved,
|
|
252
|
+
"leverage": lev,
|
|
253
|
+
"margin_mode": "cross" if is_cross else "isolated",
|
|
254
|
+
}, opts["fields"])
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Orient commands — status, balance, positions, orders."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from hl_cli.client import get_address, get_all_mids, get_info
|
|
8
|
+
from hl_cli.main import hl_app
|
|
9
|
+
from hl_cli.output import format_timestamp, output
|
|
10
|
+
from hl_cli.resolver import get_resolver
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@hl_app.command()
|
|
14
|
+
def status(
|
|
15
|
+
ctx: typer.Context,
|
|
16
|
+
address: Annotated[Optional[str], typer.Option("--address", help="Check another address")] = None,
|
|
17
|
+
):
|
|
18
|
+
"""One-shot account overview: balance + positions + open orders."""
|
|
19
|
+
opts = ctx.obj
|
|
20
|
+
info = get_info(opts["testnet"])
|
|
21
|
+
addr = get_address(address)
|
|
22
|
+
|
|
23
|
+
state = info.user_state(addr)
|
|
24
|
+
orders = info.frontend_open_orders(addr)
|
|
25
|
+
mids = get_all_mids(info)
|
|
26
|
+
|
|
27
|
+
margin = state.get("crossMarginSummary", state.get("marginSummary", {}))
|
|
28
|
+
|
|
29
|
+
positions = []
|
|
30
|
+
for p in state.get("assetPositions", []):
|
|
31
|
+
pos = p["position"]
|
|
32
|
+
size = float(pos["szi"])
|
|
33
|
+
if size == 0:
|
|
34
|
+
continue
|
|
35
|
+
coin = pos["coin"]
|
|
36
|
+
lev = pos["leverage"]
|
|
37
|
+
positions.append({
|
|
38
|
+
"asset": coin,
|
|
39
|
+
"side": "long" if size > 0 else "short",
|
|
40
|
+
"size": str(abs(size)),
|
|
41
|
+
"entry_price": pos["entryPx"],
|
|
42
|
+
"mark_price": mids.get(coin, ""),
|
|
43
|
+
"unrealized_pnl": pos["unrealizedPnl"],
|
|
44
|
+
"liquidation_price": pos.get("liquidationPx", ""),
|
|
45
|
+
"leverage": str(lev["value"]) if isinstance(lev, dict) else str(lev),
|
|
46
|
+
"margin_used": pos["marginUsed"],
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
order_list = []
|
|
50
|
+
for o in orders:
|
|
51
|
+
order_list.append({
|
|
52
|
+
"oid": o["oid"],
|
|
53
|
+
"asset": o["coin"],
|
|
54
|
+
"side": "buy" if o["side"] == "B" else "sell",
|
|
55
|
+
"price": o["limitPx"],
|
|
56
|
+
"size": o["sz"],
|
|
57
|
+
"type": (o.get("orderType") or "Limit").lower(),
|
|
58
|
+
"tif": (o.get("tif") or "").lower() or None,
|
|
59
|
+
"reduce_only": o.get("reduceOnly", False),
|
|
60
|
+
"timestamp": format_timestamp(o.get("timestamp", 0)),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
result = {
|
|
64
|
+
"account_value": margin.get("accountValue", "0"),
|
|
65
|
+
"withdrawable": state.get("withdrawable", "0"),
|
|
66
|
+
"total_margin_used": margin.get("totalMarginUsed", "0"),
|
|
67
|
+
"total_ntl_pos": margin.get("totalNtlPos", "0"),
|
|
68
|
+
"positions": positions,
|
|
69
|
+
"open_orders": order_list,
|
|
70
|
+
}
|
|
71
|
+
output(result, opts["fields"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@hl_app.command()
|
|
75
|
+
def balance(
|
|
76
|
+
ctx: typer.Context,
|
|
77
|
+
address: Annotated[Optional[str], typer.Option("--address", help="Check another address")] = None,
|
|
78
|
+
):
|
|
79
|
+
"""Account balance."""
|
|
80
|
+
opts = ctx.obj
|
|
81
|
+
info = get_info(opts["testnet"])
|
|
82
|
+
addr = get_address(address)
|
|
83
|
+
|
|
84
|
+
state = info.user_state(addr)
|
|
85
|
+
margin = state.get("crossMarginSummary", state.get("marginSummary", {}))
|
|
86
|
+
|
|
87
|
+
result = {
|
|
88
|
+
"account_value": margin.get("accountValue", "0"),
|
|
89
|
+
"total_margin_used": margin.get("totalMarginUsed", "0"),
|
|
90
|
+
"total_ntl_pos": margin.get("totalNtlPos", "0"),
|
|
91
|
+
"withdrawable": state.get("withdrawable", "0"),
|
|
92
|
+
}
|
|
93
|
+
output(result, opts["fields"])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@hl_app.command()
|
|
97
|
+
def positions(
|
|
98
|
+
ctx: typer.Context,
|
|
99
|
+
asset: Annotated[Optional[str], typer.Argument(help="Filter by asset")] = None,
|
|
100
|
+
address: Annotated[Optional[str], typer.Option("--address", help="Check another address")] = None,
|
|
101
|
+
):
|
|
102
|
+
"""Open positions with P&L."""
|
|
103
|
+
opts = ctx.obj
|
|
104
|
+
info = get_info(opts["testnet"])
|
|
105
|
+
addr = get_address(address)
|
|
106
|
+
|
|
107
|
+
state = info.user_state(addr)
|
|
108
|
+
mids = get_all_mids(info)
|
|
109
|
+
|
|
110
|
+
resolved = None
|
|
111
|
+
if asset:
|
|
112
|
+
resolver = get_resolver(info)
|
|
113
|
+
resolved = resolver.resolve(asset)
|
|
114
|
+
|
|
115
|
+
result = []
|
|
116
|
+
for p in state.get("assetPositions", []):
|
|
117
|
+
pos = p["position"]
|
|
118
|
+
size = float(pos["szi"])
|
|
119
|
+
if size == 0:
|
|
120
|
+
continue
|
|
121
|
+
coin = pos["coin"]
|
|
122
|
+
if resolved and coin != resolved:
|
|
123
|
+
continue
|
|
124
|
+
lev = pos["leverage"]
|
|
125
|
+
result.append({
|
|
126
|
+
"asset": coin,
|
|
127
|
+
"side": "long" if size > 0 else "short",
|
|
128
|
+
"size": str(abs(size)),
|
|
129
|
+
"entry_price": pos["entryPx"],
|
|
130
|
+
"mark_price": mids.get(coin, ""),
|
|
131
|
+
"unrealized_pnl": pos["unrealizedPnl"],
|
|
132
|
+
"liquidation_price": pos.get("liquidationPx", ""),
|
|
133
|
+
"leverage": str(lev["value"]) if isinstance(lev, dict) else str(lev),
|
|
134
|
+
"margin_used": pos["marginUsed"],
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
output(result, opts["fields"])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@hl_app.command()
|
|
141
|
+
def orders(
|
|
142
|
+
ctx: typer.Context,
|
|
143
|
+
asset: Annotated[Optional[str], typer.Argument(help="Filter by asset")] = None,
|
|
144
|
+
):
|
|
145
|
+
"""Open/pending orders."""
|
|
146
|
+
opts = ctx.obj
|
|
147
|
+
info = get_info(opts["testnet"])
|
|
148
|
+
addr = get_address()
|
|
149
|
+
|
|
150
|
+
raw = info.frontend_open_orders(addr)
|
|
151
|
+
|
|
152
|
+
resolved = None
|
|
153
|
+
if asset:
|
|
154
|
+
resolver = get_resolver(info)
|
|
155
|
+
resolved = resolver.resolve(asset)
|
|
156
|
+
|
|
157
|
+
result = []
|
|
158
|
+
for o in raw:
|
|
159
|
+
coin = o["coin"]
|
|
160
|
+
if resolved and coin != resolved:
|
|
161
|
+
continue
|
|
162
|
+
result.append({
|
|
163
|
+
"oid": o["oid"],
|
|
164
|
+
"asset": coin,
|
|
165
|
+
"side": "buy" if o["side"] == "B" else "sell",
|
|
166
|
+
"price": o["limitPx"],
|
|
167
|
+
"size": o["sz"],
|
|
168
|
+
"type": (o.get("orderType") or "Limit").lower(),
|
|
169
|
+
"tif": (o.get("tif") or "").lower() or None,
|
|
170
|
+
"reduce_only": o.get("reduceOnly", False),
|
|
171
|
+
"timestamp": format_timestamp(o.get("timestamp", 0)),
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
output(result, opts["fields"])
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Research commands — assets, prices, book, funding, candles."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from typing import Annotated, List, Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from hl_cli.client import get_all_mids, get_info
|
|
9
|
+
from hl_cli.main import hl_app
|
|
10
|
+
from hl_cli.output import (
|
|
11
|
+
die,
|
|
12
|
+
format_timestamp,
|
|
13
|
+
interval_to_ms,
|
|
14
|
+
output,
|
|
15
|
+
parse_time,
|
|
16
|
+
)
|
|
17
|
+
from hl_cli.resolver import get_resolver
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@hl_app.command()
|
|
21
|
+
def assets(
|
|
22
|
+
ctx: typer.Context,
|
|
23
|
+
query: Annotated[Optional[str], typer.Argument(help="Search by name")] = None,
|
|
24
|
+
deployer: Annotated[Optional[str], typer.Option("--deployer", help="Filter by deployer")] = None,
|
|
25
|
+
sort: Annotated[str, typer.Option("--sort", help="Sort: volume|oi|name")] = "volume",
|
|
26
|
+
):
|
|
27
|
+
"""List and search all tradeable perps."""
|
|
28
|
+
opts = ctx.obj
|
|
29
|
+
info = get_info(opts["testnet"])
|
|
30
|
+
|
|
31
|
+
# Default perps with full context (volume, OI, funding, etc.)
|
|
32
|
+
meta_ctxs = info.meta_and_asset_ctxs()
|
|
33
|
+
meta = meta_ctxs[0]
|
|
34
|
+
ctxs = meta_ctxs[1]
|
|
35
|
+
|
|
36
|
+
result = []
|
|
37
|
+
for i, (asset_info, asset_ctx) in enumerate(zip(meta["universe"], ctxs)):
|
|
38
|
+
result.append({
|
|
39
|
+
"asset": asset_info["name"],
|
|
40
|
+
"asset_id": i,
|
|
41
|
+
"max_leverage": asset_info.get("maxLeverage", 0),
|
|
42
|
+
"mark_price": asset_ctx.get("markPx", ""),
|
|
43
|
+
"funding_rate": asset_ctx.get("funding", ""),
|
|
44
|
+
"open_interest": asset_ctx.get("openInterest", ""),
|
|
45
|
+
"volume_24h": asset_ctx.get("dayNtlVlm", ""),
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
# HIP-3 assets — load dex metadata and try to get contexts
|
|
49
|
+
try:
|
|
50
|
+
dex_list = info.post("/info", {"type": "perpDexs"})
|
|
51
|
+
mids = get_all_mids(info)
|
|
52
|
+
|
|
53
|
+
for idx, dex_info in enumerate(dex_list[1:]):
|
|
54
|
+
dex_name = dex_info["name"]
|
|
55
|
+
try:
|
|
56
|
+
# Try metaAndAssetCtxs with dex param for full data
|
|
57
|
+
dex_data = info.post("/info", {"type": "metaAndAssetCtxs", "dex": dex_name})
|
|
58
|
+
dex_meta = dex_data[0]
|
|
59
|
+
dex_ctxs = dex_data[1]
|
|
60
|
+
offset = 110000 + idx * 10000
|
|
61
|
+
|
|
62
|
+
for j, (ai, ac) in enumerate(zip(dex_meta["universe"], dex_ctxs)):
|
|
63
|
+
name = ai["name"]
|
|
64
|
+
result.append({
|
|
65
|
+
"asset": name,
|
|
66
|
+
"asset_id": j + offset,
|
|
67
|
+
"deployer": dex_name,
|
|
68
|
+
"max_leverage": ai.get("maxLeverage", 0),
|
|
69
|
+
"mark_price": ac.get("markPx", mids.get(name, "")),
|
|
70
|
+
"funding_rate": ac.get("funding", ""),
|
|
71
|
+
"open_interest": ac.get("openInterest", ""),
|
|
72
|
+
"volume_24h": ac.get("dayNtlVlm", ""),
|
|
73
|
+
})
|
|
74
|
+
except Exception:
|
|
75
|
+
# Fallback: use meta only + all_mids for price
|
|
76
|
+
try:
|
|
77
|
+
dex_meta = info.meta(dex=dex_name)
|
|
78
|
+
for ai in dex_meta["universe"]:
|
|
79
|
+
name = ai["name"]
|
|
80
|
+
result.append({
|
|
81
|
+
"asset": name,
|
|
82
|
+
"deployer": dex_name,
|
|
83
|
+
"max_leverage": ai.get("maxLeverage", 0),
|
|
84
|
+
"mark_price": mids.get(name, ""),
|
|
85
|
+
"funding_rate": "",
|
|
86
|
+
"open_interest": "",
|
|
87
|
+
"volume_24h": "",
|
|
88
|
+
})
|
|
89
|
+
except Exception:
|
|
90
|
+
continue
|
|
91
|
+
except Exception:
|
|
92
|
+
pass # no HIP-3 data available
|
|
93
|
+
|
|
94
|
+
# Filter
|
|
95
|
+
if query:
|
|
96
|
+
q = query.upper()
|
|
97
|
+
result = [a for a in result if q in a["asset"].upper()]
|
|
98
|
+
if deployer:
|
|
99
|
+
result = [a for a in result if a.get("deployer", "") == deployer]
|
|
100
|
+
|
|
101
|
+
# Sort
|
|
102
|
+
sort_key = {"volume": "volume_24h", "oi": "open_interest", "name": "asset"}.get(sort, "volume_24h")
|
|
103
|
+
if sort_key == "asset":
|
|
104
|
+
result.sort(key=lambda x: x["asset"])
|
|
105
|
+
else:
|
|
106
|
+
result.sort(key=lambda x: float(x.get(sort_key) or "0"), reverse=True)
|
|
107
|
+
|
|
108
|
+
output(result, opts["fields"])
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@hl_app.command()
|
|
112
|
+
def prices(
|
|
113
|
+
ctx: typer.Context,
|
|
114
|
+
assets: Annotated[Optional[List[str]], typer.Argument(help="Asset names")] = None,
|
|
115
|
+
):
|
|
116
|
+
"""Current prices."""
|
|
117
|
+
opts = ctx.obj
|
|
118
|
+
info = get_info(opts["testnet"])
|
|
119
|
+
|
|
120
|
+
if not assets:
|
|
121
|
+
# Lightweight: all mid prices (including HIP-3)
|
|
122
|
+
mids = get_all_mids(info)
|
|
123
|
+
result = [{"asset": k, "mid": v} for k, v in sorted(mids.items()) if not k.startswith("@")]
|
|
124
|
+
output(result, opts["fields"])
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Detailed: per-asset info
|
|
128
|
+
resolver = get_resolver(info)
|
|
129
|
+
mids = get_all_mids(info)
|
|
130
|
+
|
|
131
|
+
# Build context lookup from default perps + HIP-3 dexes
|
|
132
|
+
meta_ctxs = info.meta_and_asset_ctxs()
|
|
133
|
+
ctx_lookup = {}
|
|
134
|
+
for asset_info, asset_ctx in zip(meta_ctxs[0]["universe"], meta_ctxs[1]):
|
|
135
|
+
ctx_lookup[asset_info["name"]] = asset_ctx
|
|
136
|
+
|
|
137
|
+
# Load HIP-3 context for any HIP-3 assets requested
|
|
138
|
+
hip3_dexes_needed = set()
|
|
139
|
+
for name in assets:
|
|
140
|
+
resolved = resolver.resolve(name)
|
|
141
|
+
if ":" in resolved and resolved not in ctx_lookup:
|
|
142
|
+
hip3_dexes_needed.add(resolved.split(":")[0])
|
|
143
|
+
for dex in hip3_dexes_needed:
|
|
144
|
+
try:
|
|
145
|
+
dex_data = info.post("/info", {"type": "metaAndAssetCtxs", "dex": dex})
|
|
146
|
+
for ai, ac in zip(dex_data[0]["universe"], dex_data[1]):
|
|
147
|
+
ctx_lookup[ai["name"]] = ac
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
result = []
|
|
152
|
+
for name in assets:
|
|
153
|
+
resolved = resolver.resolve(name)
|
|
154
|
+
ac = ctx_lookup.get(resolved, {})
|
|
155
|
+
mid = mids.get(resolved, "")
|
|
156
|
+
prev = ac.get("prevDayPx", "")
|
|
157
|
+
change = ""
|
|
158
|
+
if mid and prev:
|
|
159
|
+
try:
|
|
160
|
+
change = f"{(float(mid) / float(prev) - 1) * 100:.2f}"
|
|
161
|
+
except (ValueError, ZeroDivisionError):
|
|
162
|
+
pass
|
|
163
|
+
result.append({
|
|
164
|
+
"asset": resolved,
|
|
165
|
+
"mid": mid,
|
|
166
|
+
"mark": ac.get("markPx", mid),
|
|
167
|
+
"oracle": ac.get("oraclePx", ""),
|
|
168
|
+
"prev_day": prev,
|
|
169
|
+
"change_pct": change,
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
output(result, opts["fields"])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@hl_app.command()
|
|
176
|
+
def book(
|
|
177
|
+
ctx: typer.Context,
|
|
178
|
+
asset: Annotated[str, typer.Argument(help="Asset name")],
|
|
179
|
+
depth: Annotated[int, typer.Option("--depth", help="Levels per side (max 20)")] = 5,
|
|
180
|
+
):
|
|
181
|
+
"""Order book depth."""
|
|
182
|
+
opts = ctx.obj
|
|
183
|
+
info = get_info(opts["testnet"])
|
|
184
|
+
resolver = get_resolver(info)
|
|
185
|
+
resolved = resolver.resolve(asset)
|
|
186
|
+
|
|
187
|
+
snapshot = info.l2_snapshot(resolved)
|
|
188
|
+
levels = snapshot.get("levels", [[], []])
|
|
189
|
+
|
|
190
|
+
bids = [{"price": lv["px"], "size": lv["sz"], "orders": lv["n"]} for lv in levels[0][:depth]]
|
|
191
|
+
asks = [{"price": lv["px"], "size": lv["sz"], "orders": lv["n"]} for lv in levels[1][:depth]]
|
|
192
|
+
|
|
193
|
+
result = {"asset": resolved, "bids": bids, "asks": asks}
|
|
194
|
+
output(result, opts["fields"])
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@hl_app.command()
|
|
198
|
+
def funding(
|
|
199
|
+
ctx: typer.Context,
|
|
200
|
+
assets: Annotated[Optional[List[str]], typer.Argument(help="Asset names")] = None,
|
|
201
|
+
history: Annotated[bool, typer.Option("--history", help="Show historical rates")] = False,
|
|
202
|
+
limit: Annotated[int, typer.Option("--limit", help="Number of periods")] = 20,
|
|
203
|
+
start: Annotated[Optional[str], typer.Option("--start", help="Start time (ISO 8601)")] = None,
|
|
204
|
+
end: Annotated[Optional[str], typer.Option("--end", help="End time (ISO 8601)")] = None,
|
|
205
|
+
):
|
|
206
|
+
"""Funding rates (current or historical)."""
|
|
207
|
+
opts = ctx.obj
|
|
208
|
+
info = get_info(opts["testnet"])
|
|
209
|
+
|
|
210
|
+
if history:
|
|
211
|
+
if not assets:
|
|
212
|
+
die("validation_error", "MISSING_ARGUMENT", "Specify at least one asset for --history", hint="regard hl funding BTC --history")
|
|
213
|
+
resolver = get_resolver(info)
|
|
214
|
+
result = []
|
|
215
|
+
for name in assets:
|
|
216
|
+
resolved = resolver.resolve(name)
|
|
217
|
+
start_ms = parse_time(start) if start else int(time.time() * 1000) - limit * 8 * 3600 * 1000
|
|
218
|
+
end_ms = parse_time(end) if end else None
|
|
219
|
+
data = info.funding_history(resolved, start_ms, end_ms)
|
|
220
|
+
for h in data[-limit:]:
|
|
221
|
+
rate = h.get("fundingRate", "0")
|
|
222
|
+
result.append({
|
|
223
|
+
"asset": resolved,
|
|
224
|
+
"rate": rate,
|
|
225
|
+
"premium": h.get("premium", ""),
|
|
226
|
+
"annualized": f"{float(rate) * 3 * 365:.2f}",
|
|
227
|
+
"time": format_timestamp(h.get("time", 0)),
|
|
228
|
+
})
|
|
229
|
+
output(result, opts["fields"])
|
|
230
|
+
else:
|
|
231
|
+
# Current rates from metaAndAssetCtxs
|
|
232
|
+
meta_ctxs = info.meta_and_asset_ctxs()
|
|
233
|
+
asset_filter = {a.upper() for a in assets} if assets else None
|
|
234
|
+
|
|
235
|
+
result = []
|
|
236
|
+
for asset_info, asset_ctx in zip(meta_ctxs[0]["universe"], meta_ctxs[1]):
|
|
237
|
+
name = asset_info["name"]
|
|
238
|
+
if asset_filter and name.upper() not in asset_filter:
|
|
239
|
+
continue
|
|
240
|
+
rate = asset_ctx.get("funding", "0")
|
|
241
|
+
result.append({
|
|
242
|
+
"asset": name,
|
|
243
|
+
"rate": rate,
|
|
244
|
+
"premium": asset_ctx.get("premium", ""),
|
|
245
|
+
"annualized": f"{float(rate) * 3 * 365:.2f}",
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
output(result, opts["fields"])
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
@hl_app.command()
|
|
252
|
+
def candles(
|
|
253
|
+
ctx: typer.Context,
|
|
254
|
+
asset: Annotated[str, typer.Argument(help="Asset name")],
|
|
255
|
+
interval: Annotated[str, typer.Argument(help="1m 5m 15m 1h 4h 1d etc.")],
|
|
256
|
+
limit: Annotated[int, typer.Option("--limit", help="Number of candles (max 5000)")] = 100,
|
|
257
|
+
start: Annotated[Optional[str], typer.Option("--start", help="Start time (ISO 8601)")] = None,
|
|
258
|
+
end: Annotated[Optional[str], typer.Option("--end", help="End time (ISO 8601)")] = None,
|
|
259
|
+
):
|
|
260
|
+
"""OHLCV price history."""
|
|
261
|
+
opts = ctx.obj
|
|
262
|
+
info = get_info(opts["testnet"])
|
|
263
|
+
resolver = get_resolver(info)
|
|
264
|
+
resolved = resolver.resolve(asset)
|
|
265
|
+
|
|
266
|
+
end_ms = parse_time(end) if end else int(time.time() * 1000)
|
|
267
|
+
if start:
|
|
268
|
+
start_ms = parse_time(start)
|
|
269
|
+
else:
|
|
270
|
+
ms_per = interval_to_ms(interval)
|
|
271
|
+
start_ms = end_ms - ms_per * limit
|
|
272
|
+
|
|
273
|
+
data = info.candles_snapshot(resolved, interval, start_ms, end_ms)
|
|
274
|
+
|
|
275
|
+
result = []
|
|
276
|
+
for c in data[-limit:]:
|
|
277
|
+
result.append({
|
|
278
|
+
"time": format_timestamp(c["t"]),
|
|
279
|
+
"open": c["o"],
|
|
280
|
+
"high": c["h"],
|
|
281
|
+
"low": c["l"],
|
|
282
|
+
"close": c["c"],
|
|
283
|
+
"volume": c["v"],
|
|
284
|
+
"trades": c["n"],
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
output(result, opts["fields"])
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Review commands — fills."""
|
|
2
|
+
|
|
3
|
+
from typing import Annotated, Optional
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from hl_cli.client import get_address, get_info
|
|
8
|
+
from hl_cli.main import hl_app
|
|
9
|
+
from hl_cli.output import format_timestamp, output, parse_time
|
|
10
|
+
from hl_cli.resolver import get_resolver
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@hl_app.command()
|
|
14
|
+
def fills(
|
|
15
|
+
ctx: typer.Context,
|
|
16
|
+
asset: Annotated[Optional[str], typer.Argument(help="Filter by asset")] = None,
|
|
17
|
+
limit: Annotated[int, typer.Option("--limit", help="Number of fills (max 2000)")] = 20,
|
|
18
|
+
start: Annotated[Optional[str], typer.Option("--start", help="Start time (ISO 8601)")] = None,
|
|
19
|
+
end: Annotated[Optional[str], typer.Option("--end", help="End time (ISO 8601)")] = None,
|
|
20
|
+
):
|
|
21
|
+
"""Recent trade executions from the API."""
|
|
22
|
+
opts = ctx.obj
|
|
23
|
+
info = get_info(opts["testnet"])
|
|
24
|
+
addr = get_address()
|
|
25
|
+
|
|
26
|
+
resolved = None
|
|
27
|
+
if asset:
|
|
28
|
+
resolver = get_resolver(info)
|
|
29
|
+
resolved = resolver.resolve(asset)
|
|
30
|
+
|
|
31
|
+
if start or end:
|
|
32
|
+
start_ms = parse_time(start) if start else 0
|
|
33
|
+
end_ms = parse_time(end) if end else None
|
|
34
|
+
data = info.user_fills_by_time(addr, start_ms, end_ms)
|
|
35
|
+
else:
|
|
36
|
+
data = info.user_fills(addr)
|
|
37
|
+
|
|
38
|
+
result = []
|
|
39
|
+
for f in data:
|
|
40
|
+
coin = f["coin"]
|
|
41
|
+
if resolved and coin != resolved:
|
|
42
|
+
continue
|
|
43
|
+
result.append({
|
|
44
|
+
"asset": coin,
|
|
45
|
+
"side": "buy" if f["side"] == "B" else "sell",
|
|
46
|
+
"direction": f.get("dir", ""),
|
|
47
|
+
"price": f["px"],
|
|
48
|
+
"size": f["sz"],
|
|
49
|
+
"fee": f.get("fee", ""),
|
|
50
|
+
"closed_pnl": f.get("closedPnl", "0"),
|
|
51
|
+
"time": format_timestamp(f.get("time", 0)),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
result = result[-limit:]
|
|
55
|
+
output(result, opts["fields"])
|
|
56
|
+
|
|
57
|
+
|
hl_cli/main.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""regard — AI trading partner for the unified speculator."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version as pkg_version
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _version_callback(value: bool):
|
|
10
|
+
if value:
|
|
11
|
+
print(pkg_version("regard-cli"))
|
|
12
|
+
raise typer.Exit()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(
|
|
16
|
+
name="regard",
|
|
17
|
+
add_completion=False,
|
|
18
|
+
no_args_is_help=True,
|
|
19
|
+
help="AI trading partner for the unified speculator.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
hl_app = typer.Typer(
|
|
23
|
+
name="hl",
|
|
24
|
+
no_args_is_help=True,
|
|
25
|
+
help="Hyperliquid perp trading. JSON output by default.",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@app.callback()
|
|
30
|
+
def main(
|
|
31
|
+
ctx: typer.Context,
|
|
32
|
+
version: bool = typer.Option(False, "--version", callback=_version_callback, is_eager=True, help="Show version and exit"),
|
|
33
|
+
fields: Optional[str] = typer.Option(None, "--fields", help="Comma-separated field projection"),
|
|
34
|
+
verbose: bool = typer.Option(False, "--verbose", help="Debug info to stderr"),
|
|
35
|
+
):
|
|
36
|
+
"""Regard CLI — multi-venue trading."""
|
|
37
|
+
ctx.ensure_object(dict)
|
|
38
|
+
ctx.obj["fields"] = fields
|
|
39
|
+
ctx.obj["verbose"] = verbose
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@hl_app.callback()
|
|
43
|
+
def hl_main(
|
|
44
|
+
ctx: typer.Context,
|
|
45
|
+
testnet: bool = typer.Option(False, "--testnet", help="Use testnet endpoints"),
|
|
46
|
+
):
|
|
47
|
+
"""Hyperliquid perp trading CLI."""
|
|
48
|
+
ctx.ensure_object(dict)
|
|
49
|
+
ctx.obj["testnet"] = testnet
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
app.add_typer(hl_app)
|
|
53
|
+
|
|
54
|
+
# Register all commands on hl_app — must be after hl_app is defined
|
|
55
|
+
from hl_cli.commands import orient, research, act, review # noqa: E402, F401
|
hl_cli/output.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Output formatting, errors, and time utilities."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sys
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def output(data, fields=None):
|
|
9
|
+
"""Write data to stdout as envelope JSON."""
|
|
10
|
+
if fields:
|
|
11
|
+
data = _project(data, fields)
|
|
12
|
+
envelope = {
|
|
13
|
+
"ok": True,
|
|
14
|
+
"data": data,
|
|
15
|
+
"meta": {
|
|
16
|
+
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
17
|
+
},
|
|
18
|
+
}
|
|
19
|
+
sys.stdout.write(json.dumps(envelope, separators=(",", ":")) + "\n")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def die(
|
|
23
|
+
error_type: str,
|
|
24
|
+
code: str,
|
|
25
|
+
message: str,
|
|
26
|
+
*,
|
|
27
|
+
hint: str | None = None,
|
|
28
|
+
retryable: bool = False,
|
|
29
|
+
param: str | None = None,
|
|
30
|
+
extra: dict | None = None,
|
|
31
|
+
exit_code: int = 2,
|
|
32
|
+
):
|
|
33
|
+
"""Write structured error envelope to stdout and exit."""
|
|
34
|
+
error = {"type": error_type, "code": code, "message": message, "retryable": retryable}
|
|
35
|
+
if hint:
|
|
36
|
+
error["hint"] = hint
|
|
37
|
+
if param:
|
|
38
|
+
error["param"] = param
|
|
39
|
+
if extra:
|
|
40
|
+
error.update(extra)
|
|
41
|
+
envelope = {
|
|
42
|
+
"ok": False,
|
|
43
|
+
"error": error,
|
|
44
|
+
"meta": {"exit_code": exit_code},
|
|
45
|
+
}
|
|
46
|
+
sys.stdout.write(json.dumps(envelope, separators=(",", ":")) + "\n")
|
|
47
|
+
raise SystemExit(exit_code)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def debug(message: str, verbose: bool = False):
|
|
51
|
+
"""Write debug info to stderr."""
|
|
52
|
+
if verbose:
|
|
53
|
+
sys.stderr.write(f"[debug] {message}\n")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _project(data, fields_str: str):
|
|
57
|
+
"""Filter data to only include specified fields."""
|
|
58
|
+
fields = {f.strip() for f in fields_str.split(",")}
|
|
59
|
+
if isinstance(data, list):
|
|
60
|
+
return [
|
|
61
|
+
{k: v for k, v in item.items() if k in fields}
|
|
62
|
+
for item in data
|
|
63
|
+
if isinstance(item, dict)
|
|
64
|
+
]
|
|
65
|
+
elif isinstance(data, dict):
|
|
66
|
+
return {k: v for k, v in data.items() if k in fields}
|
|
67
|
+
return data
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_time(s: str) -> int:
|
|
71
|
+
"""Parse time string to unix milliseconds."""
|
|
72
|
+
dt = datetime.fromisoformat(s)
|
|
73
|
+
if dt.tzinfo is None:
|
|
74
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
75
|
+
return int(dt.timestamp() * 1000)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def format_timestamp(ms) -> str:
|
|
79
|
+
"""Format unix milliseconds to ISO 8601."""
|
|
80
|
+
if isinstance(ms, (int, float)) and ms > 0:
|
|
81
|
+
dt = datetime.fromtimestamp(ms / 1000, tz=timezone.utc)
|
|
82
|
+
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
83
|
+
return str(ms)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
INTERVAL_MS = {
|
|
87
|
+
"1m": 60_000,
|
|
88
|
+
"3m": 180_000,
|
|
89
|
+
"5m": 300_000,
|
|
90
|
+
"15m": 900_000,
|
|
91
|
+
"30m": 1_800_000,
|
|
92
|
+
"1h": 3_600_000,
|
|
93
|
+
"2h": 7_200_000,
|
|
94
|
+
"4h": 14_400_000,
|
|
95
|
+
"8h": 28_800_000,
|
|
96
|
+
"12h": 43_200_000,
|
|
97
|
+
"1d": 86_400_000,
|
|
98
|
+
"3d": 259_200_000,
|
|
99
|
+
"1w": 604_800_000,
|
|
100
|
+
"1M": 2_592_000_000,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def interval_to_ms(interval: str) -> int:
|
|
105
|
+
"""Convert candle interval string to milliseconds."""
|
|
106
|
+
ms = INTERVAL_MS.get(interval)
|
|
107
|
+
if ms is None:
|
|
108
|
+
valid = ", ".join(INTERVAL_MS.keys())
|
|
109
|
+
die("validation_error", "INVALID_INTERVAL", f"Invalid interval: {interval}", hint=f"Valid intervals: {valid}", param="interval")
|
|
110
|
+
return ms
|
hl_cli/resolver.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Asset name resolution — maps short tickers to full coin names."""
|
|
2
|
+
|
|
3
|
+
from hyperliquid.info import Info
|
|
4
|
+
|
|
5
|
+
from hl_cli.output import die
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Resolver:
|
|
9
|
+
"""Resolves user-typed asset names to SDK coin names.
|
|
10
|
+
|
|
11
|
+
- Exact match on known coin (e.g. "BTC", "xyz:TSLA") -> use it
|
|
12
|
+
- Case-insensitive match on default perps -> use it
|
|
13
|
+
- Short HIP-3 ticker (e.g. "TSLA") -> resolve, error if ambiguous
|
|
14
|
+
- Lazy-loads HIP-3 dex metadata on first miss against default perps
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, info: Info):
|
|
18
|
+
self.info = info
|
|
19
|
+
self._dexes_loaded = False
|
|
20
|
+
self._short_map: dict[str, list[str]] = {} # UPPER ticker -> [full names]
|
|
21
|
+
self._build_default_map()
|
|
22
|
+
|
|
23
|
+
def _build_default_map(self):
|
|
24
|
+
"""Index default perps by uppercase name."""
|
|
25
|
+
for name in self.info.coin_to_asset:
|
|
26
|
+
if ":" not in name and "@" not in name:
|
|
27
|
+
key = name.upper()
|
|
28
|
+
self._short_map.setdefault(key, []).append(name)
|
|
29
|
+
|
|
30
|
+
def _load_dexes(self):
|
|
31
|
+
"""Lazily load all HIP-3 deployer metadata into the Info client."""
|
|
32
|
+
if self._dexes_loaded:
|
|
33
|
+
return
|
|
34
|
+
self._dexes_loaded = True
|
|
35
|
+
|
|
36
|
+
try:
|
|
37
|
+
dex_list = self.info.post("/info", {"type": "perpDexs"})
|
|
38
|
+
except Exception:
|
|
39
|
+
return # can't load dexes, resolve will fail with unknown_asset
|
|
40
|
+
|
|
41
|
+
for idx, dex_info in enumerate(dex_list[1:]): # skip default dex
|
|
42
|
+
dex_name = dex_info["name"]
|
|
43
|
+
try:
|
|
44
|
+
dex_meta = self.info.meta(dex=dex_name)
|
|
45
|
+
offset = 110000 + idx * 10000
|
|
46
|
+
self.info.set_perp_meta(dex_meta, offset)
|
|
47
|
+
|
|
48
|
+
# Index by short ticker for disambiguation
|
|
49
|
+
for asset_info in dex_meta["universe"]:
|
|
50
|
+
full_name = asset_info["name"]
|
|
51
|
+
# The API may or may not prefix with dex name
|
|
52
|
+
if ":" in full_name:
|
|
53
|
+
_, ticker = full_name.split(":", 1)
|
|
54
|
+
else:
|
|
55
|
+
ticker = full_name
|
|
56
|
+
key = ticker.upper()
|
|
57
|
+
self._short_map.setdefault(key, []).append(full_name)
|
|
58
|
+
except Exception:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
def _find_exact(self, name: str) -> str | None:
|
|
62
|
+
"""Case-insensitive exact match against all known coins."""
|
|
63
|
+
if name in self.info.coin_to_asset:
|
|
64
|
+
return name
|
|
65
|
+
upper = name.upper()
|
|
66
|
+
for known in self.info.coin_to_asset:
|
|
67
|
+
if known.upper() == upper:
|
|
68
|
+
return known
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
def resolve(self, name: str) -> str:
|
|
72
|
+
"""Resolve a user-typed asset name to the SDK coin name.
|
|
73
|
+
|
|
74
|
+
Returns the canonical coin name or calls die() on error.
|
|
75
|
+
"""
|
|
76
|
+
# Exact match (already known to SDK)
|
|
77
|
+
exact = self._find_exact(name)
|
|
78
|
+
if exact:
|
|
79
|
+
return exact
|
|
80
|
+
|
|
81
|
+
# Short-name match against indexed tickers
|
|
82
|
+
key = name.upper()
|
|
83
|
+
matches = self._short_map.get(key)
|
|
84
|
+
|
|
85
|
+
if matches is None and not self._dexes_loaded:
|
|
86
|
+
# Try loading HIP-3 dexes
|
|
87
|
+
self._load_dexes()
|
|
88
|
+
# Re-check exact match (full names now loaded)
|
|
89
|
+
exact = self._find_exact(name)
|
|
90
|
+
if exact:
|
|
91
|
+
return exact
|
|
92
|
+
matches = self._short_map.get(key)
|
|
93
|
+
|
|
94
|
+
if not matches:
|
|
95
|
+
die(
|
|
96
|
+
"validation_error", "UNKNOWN_ASSET",
|
|
97
|
+
f"Unknown asset: {name}",
|
|
98
|
+
hint="Use 'regard hl assets' to list available assets, or specify full name like 'xyz:TSLA'",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if len(matches) == 1:
|
|
102
|
+
return matches[0]
|
|
103
|
+
|
|
104
|
+
# Multiple matches — prefer default perp (no colon) over HIP-3
|
|
105
|
+
defaults = [m for m in matches if ":" not in m]
|
|
106
|
+
if len(defaults) == 1:
|
|
107
|
+
return defaults[0]
|
|
108
|
+
|
|
109
|
+
# Ambiguous — build match info for the error
|
|
110
|
+
mids = {}
|
|
111
|
+
try:
|
|
112
|
+
mids = self.info.all_mids()
|
|
113
|
+
# Also fetch HIP-3 mids for the deployers involved
|
|
114
|
+
dex_list = self.info.post("/info", {"type": "perpDexs"})
|
|
115
|
+
deployers = {m.split(":")[0] for m in matches if ":" in m}
|
|
116
|
+
for dex_info in dex_list[1:]:
|
|
117
|
+
if dex_info["name"] in deployers:
|
|
118
|
+
dex_mids = self.info.post("/info", {"type": "allMids", "dex": dex_info["name"]})
|
|
119
|
+
mids.update(dex_mids)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
match_info = []
|
|
124
|
+
for m in matches:
|
|
125
|
+
deployer = m.split(":")[0] if ":" in m else ""
|
|
126
|
+
match_info.append({"asset": m, "deployer": deployer, "mid": mids.get(m, "")})
|
|
127
|
+
|
|
128
|
+
die(
|
|
129
|
+
"validation_error", "AMBIGUOUS_ASSET",
|
|
130
|
+
f"{name} matches multiple markets",
|
|
131
|
+
hint=f"Use full name: regard hl <command> {matches[0]} ...",
|
|
132
|
+
extra={"matches": match_info},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# Module-level singleton per Info instance
|
|
137
|
+
_resolvers: dict[int, Resolver] = {}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_resolver(info: Info) -> Resolver:
|
|
141
|
+
"""Get or create a Resolver for the given Info client."""
|
|
142
|
+
key = id(info)
|
|
143
|
+
if key not in _resolvers:
|
|
144
|
+
_resolvers[key] = Resolver(info)
|
|
145
|
+
return _resolvers[key]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: regard-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: AI trading partner for the unified speculator — Hyperliquid + Polymarket
|
|
5
|
+
Author: tab55
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: agent,cli,defi,hyperliquid,polymarket,regard,trading
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Office/Business :: Financial :: Investment
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Requires-Dist: hyperliquid-python-sdk>=0.22.0
|
|
21
|
+
Requires-Dist: typer>=0.9.0
|
|
22
|
+
Provides-Extra: test
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'test'
|
|
24
|
+
Requires-Dist: syrupy>=4.0; extra == 'test'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# regard-cli
|
|
28
|
+
|
|
29
|
+
AI trading partner for the unified speculator. Multi-venue trading CLI consumed by the [regard-computer](https://github.com/akegaviar/tabtabtabTABtab) Claude Code skill.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uv tool install regard-cli
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# Read commands (no auth needed)
|
|
41
|
+
regard hl prices BTC ETH
|
|
42
|
+
regard hl book BTC --depth 10
|
|
43
|
+
regard hl assets TSLA
|
|
44
|
+
regard hl funding BTC --history
|
|
45
|
+
regard hl candles BTC 1h --limit 50
|
|
46
|
+
|
|
47
|
+
# Account commands (needs HYPERLIQUID_AGENT_KEY or --address)
|
|
48
|
+
regard hl status
|
|
49
|
+
regard hl balance
|
|
50
|
+
regard hl positions
|
|
51
|
+
regard hl orders
|
|
52
|
+
regard hl fills
|
|
53
|
+
|
|
54
|
+
# Write commands (needs HYPERLIQUID_AGENT_KEY)
|
|
55
|
+
regard hl order BTC buy 0.1 --market
|
|
56
|
+
regard hl order BTC buy 0.1 95000 --tp 100000 --sl 90000
|
|
57
|
+
regard hl cancel 77738308
|
|
58
|
+
regard hl cancel-all BTC
|
|
59
|
+
regard hl leverage BTC 10
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Auth
|
|
63
|
+
|
|
64
|
+
Set `HYPERLIQUID_AGENT_KEY` env var with your Hyperliquid agent wallet key (can trade, cannot withdraw). Master address auto-resolved via `userRole` API.
|
|
65
|
+
|
|
66
|
+
Read-only commands work without auth using `--address`.
|
|
67
|
+
|
|
68
|
+
## Design
|
|
69
|
+
|
|
70
|
+
- Venue-first sub-commands (`regard hl`, `regard poly`, ...)
|
|
71
|
+
- Output envelope: `{"ok": true, "data": ..., "meta": {"timestamp": ...}}`
|
|
72
|
+
- Structured errors with type/code/message, recovery hints, retryable flag
|
|
73
|
+
- Exit codes: 0=success, 2=validation, 3=auth, 4=venue, 5=rate_limit, 6=network
|
|
74
|
+
- Field projection via `--fields`
|
|
75
|
+
- HIP-3 asset auto-resolution (e.g. `TSLA` → `xyz:TSLA`)
|
|
76
|
+
- CLI-first, MCP-ready — every command maps 1:1 to an MCP tool definition
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
hl_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
hl_cli/client.py,sha256=Lls1Mvpr2DIu02eGTrgHcRmwHzogCbHhSAG4myMWoPA,3271
|
|
3
|
+
hl_cli/main.py,sha256=wy3LiVDJVjoUZ4JeZEF1TsRr0DuXbDisnVTi4noFImk,1485
|
|
4
|
+
hl_cli/output.py,sha256=eL_TcXoKvZgFH2judiaWKvwMyIDNnH6c4iBi0IlxDt8,2980
|
|
5
|
+
hl_cli/resolver.py,sha256=o90yMRk9ZxSLZEwf04burOOK0QS7-VbGiSs2sCdx6_Q,5202
|
|
6
|
+
hl_cli/commands/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
hl_cli/commands/act.py,sha256=uPytUzkYSCJXEiBedSeQn3TAYruGqBRFNdn_9YUxhJY,9230
|
|
8
|
+
hl_cli/commands/orient.py,sha256=GV-7ycPlefAlCdp73UedfEDTtOETW_iHkbEgtaPDJAw,5457
|
|
9
|
+
hl_cli/commands/research.py,sha256=daeLdOF90B9meUbRH7wsJrIfIPlmpDDB4wHLCEv4DjQ,10432
|
|
10
|
+
hl_cli/commands/review.py,sha256=cc-cCLYHDVcUSsqv1mq9eB8wfY6p3MIb_iBC2PLEgmc,1735
|
|
11
|
+
regard_cli-0.2.0.dist-info/METADATA,sha256=lgEfhN4RjjFIIQoHXKgFiTziUVSdVxfChEWFLdfDFdM,2516
|
|
12
|
+
regard_cli-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
13
|
+
regard_cli-0.2.0.dist-info/entry_points.txt,sha256=jJPHzQ2IFU51f9vFGUwBaKiZrROf-wB1XNtQXno5RC4,43
|
|
14
|
+
regard_cli-0.2.0.dist-info/licenses/LICENSE,sha256=cGXuzMAOqozvfDc6j_SY0yqCr5jABYqf1m0De5VQN_k,1062
|
|
15
|
+
regard_cli-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tab55
|
|
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.
|