Skip to content

Commit 476a964

Browse files
Michael YuanMichael Yuan
authored andcommitted
feat: commodity perp quote and trading on HL (SILVER, GOLD)
- perp quote SILVER → fetches SLV/USDC spot pair data from HL - perp quote GOLD → fetches XAUT0/USDC spot pair data from HL - perp buy/sell SILVER routes to SLV/USDC spot order automatically - perp buy/sell GOLD routes to XAUT0/USDC spot order automatically - Commodity aliases: SILVER/XAG→SLV, GOLD/XAU/XAUT→XAUT0 - Human output shows source, note, and spot pair info for commodities - Falls back from perp universe to spot universe for commodity symbols
1 parent df5a427 commit 476a964

File tree

2 files changed

+275
-1
lines changed

2 files changed

+275
-1
lines changed

src/commands/perp.rs

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use anyhow::{bail, Context, Result};
22
use colored::Colorize;
33
use serde_json::json;
44

5+
use crate::commands::quote::commodity_to_spot_token;
56
use crate::{binance, config, signing};
67

78
/// Resolve which exchange to use
@@ -68,6 +69,11 @@ pub async fn buy(
6869
let amount_f: f64 = amount_usdc.parse().context("Invalid amount")?;
6970
let size = amount_f / price_f;
7071

72+
// Check if this is a commodity → route to spot order
73+
if let Some(spot_token) = commodity_to_spot_token(&symbol) {
74+
return commodity_spot_buy(spot_token, &symbol, price_f, size, &cfg, json_output).await;
75+
}
76+
7177
if !json_output {
7278
println!();
7379
println!(" 📝 Placing perp limit BUY (long)");
@@ -150,6 +156,11 @@ pub async fn sell(
150156
let size: f64 = amount.parse().context("Invalid amount")?;
151157
let price_f: f64 = price.parse().context("Invalid price")?;
152158

159+
// Check if this is a commodity → route to spot order
160+
if let Some(spot_token) = commodity_to_spot_token(&symbol) {
161+
return commodity_spot_sell(spot_token, &symbol, price_f, size, &cfg, json_output).await;
162+
}
163+
153164
if !json_output {
154165
println!();
155166
println!(" 📝 Placing perp limit SELL (short)");
@@ -191,3 +202,123 @@ pub async fn sell(
191202

192203
Ok(())
193204
}
205+
206+
// ── Commodity spot order helpers ─────────────────────────────────────
207+
208+
/// Route a commodity "perp buy" to a spot buy on HL
209+
async fn commodity_spot_buy(
210+
spot_token: &str,
211+
original_symbol: &str,
212+
price: f64,
213+
size: f64,
214+
cfg: &config::HlConfig,
215+
json_output: bool,
216+
) -> Result<()> {
217+
if !json_output {
218+
println!();
219+
println!(
220+
" 📝 {} trades as {}/USDC spot on Hyperliquid",
221+
original_symbol,
222+
spot_token
223+
);
224+
println!(" Placing spot limit BUY");
225+
println!(" Token: {}", spot_token.cyan());
226+
println!(" Size: {:.6}", size);
227+
println!(" Price: ${:.2}", price);
228+
println!(" Total: ${:.2}", price * size);
229+
println!(
230+
" Network: {}",
231+
if cfg.testnet { "Testnet" } else { "Mainnet" }
232+
);
233+
println!();
234+
}
235+
236+
let result = signing::place_spot_order(spot_token, true, price, size).await?;
237+
238+
let response = json!({
239+
"action": "commodity_spot_buy",
240+
"symbol": original_symbol,
241+
"spotToken": spot_token,
242+
"size": format!("{:.6}", size),
243+
"price": format!("{:.2}", price),
244+
"total_usdc": format!("{:.2}", price * size),
245+
"network": if cfg.testnet { "testnet" } else { "mainnet" },
246+
"note": format!("{} trades as {}/USDC spot pair (no perp available)", original_symbol, spot_token),
247+
"result": format!("{:?}", result),
248+
});
249+
250+
if json_output {
251+
println!("{}", serde_json::to_string_pretty(&response)?);
252+
} else {
253+
match result {
254+
hyperliquid_rust_sdk::ExchangeResponseStatus::Ok(data) => {
255+
println!(" ✅ Spot order placed!");
256+
println!(" Response: {:?}", data);
257+
}
258+
hyperliquid_rust_sdk::ExchangeResponseStatus::Err(e) => {
259+
println!(" ❌ Order failed: {}", e);
260+
}
261+
}
262+
println!();
263+
}
264+
265+
Ok(())
266+
}
267+
268+
/// Route a commodity "perp sell" to a spot sell on HL
269+
async fn commodity_spot_sell(
270+
spot_token: &str,
271+
original_symbol: &str,
272+
price: f64,
273+
size: f64,
274+
cfg: &config::HlConfig,
275+
json_output: bool,
276+
) -> Result<()> {
277+
if !json_output {
278+
println!();
279+
println!(
280+
" 📝 {} trades as {}/USDC spot on Hyperliquid",
281+
original_symbol,
282+
spot_token
283+
);
284+
println!(" Placing spot limit SELL");
285+
println!(" Token: {}", spot_token.cyan());
286+
println!(" Size: {:.6}", size);
287+
println!(" Price: ${:.2}", price);
288+
println!(
289+
" Network: {}",
290+
if cfg.testnet { "Testnet" } else { "Mainnet" }
291+
);
292+
println!();
293+
}
294+
295+
let result = signing::place_spot_order(spot_token, false, price, size).await?;
296+
297+
let response = json!({
298+
"action": "commodity_spot_sell",
299+
"symbol": original_symbol,
300+
"spotToken": spot_token,
301+
"size": format!("{:.6}", size),
302+
"price": format!("{:.2}", price),
303+
"network": if cfg.testnet { "testnet" } else { "mainnet" },
304+
"note": format!("{} trades as {}/USDC spot pair (no perp available)", original_symbol, spot_token),
305+
"result": format!("{:?}", result),
306+
});
307+
308+
if json_output {
309+
println!("{}", serde_json::to_string_pretty(&response)?);
310+
} else {
311+
match result {
312+
hyperliquid_rust_sdk::ExchangeResponseStatus::Ok(data) => {
313+
println!(" ✅ Spot order placed!");
314+
println!(" Response: {:?}", data);
315+
}
316+
hyperliquid_rust_sdk::ExchangeResponseStatus::Err(e) => {
317+
println!(" ❌ Order failed: {}", e);
318+
}
319+
}
320+
println!();
321+
}
322+
323+
Ok(())
324+
}

src/commands/quote.rs

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,9 +357,141 @@ async fn fetch_hl_perp(client: &reqwest::Client, symbol: &str) -> Result<Value>
357357
}
358358
}
359359

360+
// Not found in perps — check if it's a commodity with a spot pair
361+
let commodity_token = commodity_to_spot_token(symbol);
362+
if let Some(token) = commodity_token {
363+
return fetch_hl_commodity_as_perp(client, symbol, token).await;
364+
}
365+
360366
anyhow::bail!("Symbol {} not found in Hyperliquid perps", symbol)
361367
}
362368

369+
/// Map commodity aliases to HL spot token names
370+
pub fn commodity_to_spot_token(symbol: &str) -> Option<&'static str> {
371+
match symbol.to_uppercase().as_str() {
372+
"SILVER" | "XAG" => Some("SLV"),
373+
"GOLD" | "XAU" | "XAUT" => Some("XAUT0"),
374+
"SLV" => Some("SLV"),
375+
"XAUT0" => Some("XAUT0"),
376+
"GLD" => Some("GLD"),
377+
_ => None,
378+
}
379+
}
380+
381+
/// Fetch commodity spot data and format it like a perp quote
382+
async fn fetch_hl_commodity_as_perp(
383+
client: &reqwest::Client,
384+
original_symbol: &str,
385+
token_name: &str,
386+
) -> Result<Value> {
387+
let url = config::info_url();
388+
389+
let meta_resp: Value = client
390+
.post(&url)
391+
.json(&json!({"type": "spotMetaAndAssetCtxs"}))
392+
.send()
393+
.await?
394+
.json()
395+
.await
396+
.context("Failed to parse spotMetaAndAssetCtxs")?;
397+
398+
let tokens = meta_resp
399+
.get(0)
400+
.and_then(|m| m.get("tokens"))
401+
.and_then(|t| t.as_array())
402+
.ok_or_else(|| anyhow::anyhow!("Failed to get spot tokens"))?;
403+
404+
let universe = meta_resp
405+
.get(0)
406+
.and_then(|m| m.get("universe"))
407+
.and_then(|u| u.as_array())
408+
.ok_or_else(|| anyhow::anyhow!("Failed to get spot universe"))?;
409+
410+
let ctxs = meta_resp
411+
.get(1)
412+
.and_then(|c| c.as_array())
413+
.ok_or_else(|| anyhow::anyhow!("Failed to get spot contexts"))?;
414+
415+
// Find token index
416+
let token_idx = tokens
417+
.iter()
418+
.find(|t| {
419+
t.get("name")
420+
.and_then(|n| n.as_str())
421+
.map(|n| n.eq_ignore_ascii_case(token_name))
422+
.unwrap_or(false)
423+
})
424+
.and_then(|t| t.get("index").and_then(|i| i.as_u64()))
425+
.ok_or_else(|| anyhow::anyhow!("Token {} not found on HL spot", token_name))?;
426+
427+
// Find the pair with this token and USDC (index 0)
428+
for (i, pair) in universe.iter().enumerate() {
429+
if let Some(pair_tokens) = pair.get("tokens").and_then(|t| t.as_array()) {
430+
let indices: Vec<u64> = pair_tokens.iter().filter_map(|t| t.as_u64()).collect();
431+
if indices.contains(&token_idx) && indices.contains(&0) {
432+
if let Some(ctx) = ctxs.get(i) {
433+
let mark_px = ctx
434+
.get("markPx")
435+
.and_then(|f| f.as_str())
436+
.unwrap_or("")
437+
.to_string();
438+
let prev_day_px = ctx
439+
.get("prevDayPx")
440+
.and_then(|f| f.as_str())
441+
.unwrap_or("")
442+
.to_string();
443+
let volume = ctx
444+
.get("dayNtlVlm")
445+
.and_then(|f| f.as_str())
446+
.unwrap_or("")
447+
.to_string();
448+
let mid_px = ctx
449+
.get("midPx")
450+
.and_then(|f| f.as_str())
451+
.unwrap_or("")
452+
.to_string();
453+
let supply = ctx
454+
.get("circulatingSupply")
455+
.and_then(|f| f.as_str())
456+
.unwrap_or("")
457+
.to_string();
458+
let change_24h = calc_change(&mark_px, &prev_day_px);
459+
let pair_name = pair
460+
.get("name")
461+
.and_then(|n| n.as_str())
462+
.unwrap_or("")
463+
.to_string();
464+
465+
return Ok(json!({
466+
"symbol": original_symbol,
467+
"spotToken": token_name,
468+
"spotPair": pair_name,
469+
"markPx": mark_px,
470+
"midPx": mid_px,
471+
"oraclePx": null,
472+
"change24h": change_24h,
473+
"funding": null,
474+
"premium": null,
475+
"openInterest": null,
476+
"volume24h": volume,
477+
"prevDayPx": prev_day_px,
478+
"circulatingSupply": supply,
479+
"maxLeverage": 1,
480+
"source": "Hyperliquid Spot (commodity)",
481+
"note": format!("{} trades as {}/USDC spot pair on Hyperliquid (no perp available)", original_symbol, token_name)
482+
}));
483+
}
484+
}
485+
}
486+
}
487+
488+
anyhow::bail!(
489+
"Commodity {} (token {}) not found as spot pair on Hyperliquid",
490+
original_symbol,
491+
token_name
492+
)
493+
}
494+
363495
async fn fetch_yahoo_quote(client: &reqwest::Client, symbol: &str) -> Result<Value> {
364496
// If symbol is a known crypto, try -USD first (avoid stock ticker collisions like BTC=Grayscale)
365497
let is_crypto = coingecko_symbol_map().contains_key(symbol);
@@ -820,6 +952,17 @@ fn print_perp_quote(data: &Value) {
820952
println!(" Open Interest: {}", oi);
821953
println!(" 24h Volume: ${}", vol);
822954
println!(" Max Leverage: {}x", max_lev);
823-
println!(" Source: {}", "Hyperliquid".yellow());
955+
let source = data["source"].as_str().unwrap_or("Hyperliquid");
956+
println!(" Source: {}", source.yellow());
957+
if let Some(note) = data["note"].as_str() {
958+
println!(" ℹ️ {}", note.dimmed());
959+
}
960+
if let Some(token) = data["spotToken"].as_str() {
961+
println!(
962+
" Spot Pair: {}/USDC ({})",
963+
token.cyan(),
964+
data["spotPair"].as_str().unwrap_or("")
965+
);
966+
}
824967
println!();
825968
}

0 commit comments

Comments
 (0)