fix(hl): size dollars for the market named now and keep timed cancels in order

Fixes from Greptile's review of f1ff1b3:

- The ticket reads its market at the start of a frame, before an edit
  to the market field. A dollar amount could then be sized at the old
  market's mid ($10 at a $2 mid is 5 units, worth $20 on a $4 market).
  settle_size now sizes only with the market the ticket names, so the
  next frame sizes it for the new one. The ticket's estimate also uses
  that market.
- Dead man's switch changes ran side by side, so a slow clear could
  land after a newer schedule and remove it. They now go out one at a
  time in the order the app made them, on a lock of their own that
  never waits for orders. A change that reaches the worker after a
  newer one is dropped. After a change with no clear answer, the next
  waits until it can no longer land.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Michael ShimelesandClaude Opus 5.5 committed 2026-11-03 10:58:31 -05:00
1 parent f1ff1b3a75
commit 2f7cab6e20
4 files changed
+197 -30

No files matched your search

+65 -10
View File
@@ -450,15 +450,25 @@ fn parse_num(s: &str) -> Option<f64> {
}
/// A dollar amount on a Hyperliquid ticket, as in `BUY xyz:AAPL $50`, becomes a size at the
/// testnet mid, rounded down to the market's size step so the order is never worth more than
/// the amount. Until there is a mid, or when the amount is under one step, it stays a dollar
/// amount, which [`intent`] refuses. Returns why it isn't a size yet.
pub fn settle_size(t: &mut super::trading::Ticket, mid: Option<f64>, asset: Option<&Asset>) -> Option<String> {
/// testnet mid of the market the ticket names, rounded down to that market's size step so the
/// order is never worth more than the amount. Until there is a mid for that market, or when
/// the amount is under one step, it stays a dollar amount, which [`intent`] refuses. Returns
/// why it isn't a size yet.
pub fn settle_size(
t: &mut super::trading::Ticket,
reg: &AssetRegistry,
mid: Option<f64>,
asset: Option<&Asset>,
) -> Option<String> {
if !t.dollars {
return None;
}
let amount = parse_num(&t.qty).filter(|a| a.is_finite() && *a > 0.0)?;
let dollars = tr_hl::rounding::to_wire(amount).unwrap_or_else(|_| format!("{amount:.2}"));
// Only the market the ticket names now. The caller may have read `asset` and `mid`
// before the user edited the market.
let named = resolve_coin(&t.symbol, reg).ok();
let asset = asset.filter(|a| named.as_deref() == Some(a.coin.as_str()));
let (Some(a), Some(mid)) = (asset, mid.filter(|m| m.is_finite() && *m > 0.0)) else {
return Some(format!("Waiting for the testnet mid to size ${dollars}…"));
};
@@ -478,6 +488,15 @@ pub fn settle_size(t: &mut super::trading::Ticket, mid: Option<f64>, asset: Opti
None
}
/// The user edited the ticket's market.
pub fn market_edited(t: &mut super::trading::Ticket) {
// If the size came from dollars, size the amount again for the new market.
if let Some(d) = t.hl.from_dollars.take() {
t.dollars = true;
t.qty = tr_hl::rounding::to_wire(d).unwrap_or_default();
}
}
/// The order a Hyperliquid ticket describes.
pub fn intent(t: &super::trading::Ticket, reg: &AssetRegistry) -> Result<OrderIntent, String> {
let coin = resolve_coin(&t.symbol, reg)?;
@@ -1325,38 +1344,74 @@ mod tests {
};
// With no testnet mid yet, the amount stays in dollars and intent() refuses it.
let mut t = dollars("xyz:INTC", 10.0);
let note = settle_size(&mut t, None, Some(&intc));
let note = settle_size(&mut t, &reg, None, Some(&intc));
let e = intent(&t, &reg).expect_err("$10 is never read as 10 contracts");
assert!(e.contains("$10") && e.contains("dollar"), "{e}");
assert!(t.dollars);
assert_eq!(note.as_deref(), Some("Waiting for the testnet mid to size $10…"));
// At a $2 mid, $10 is 5 contracts.
assert_eq!(settle_size(&mut t, Some(2.0), Some(&intc)), None);
assert_eq!(settle_size(&mut t, &reg, Some(2.0), Some(&intc)), None);
assert!(!t.dollars);
assert_eq!((t.qty.as_str(), t.hl.from_dollars), ("5", Some(10.0)));
assert_eq!(intent(&t, &reg).unwrap().size, 5.0);
// Rounded down to the size step (2 decimals), so it is never worth more than $10.
let mut t = dollars("xyz:INTC", 10.0);
settle_size(&mut t, Some(3.0), Some(&intc));
settle_size(&mut t, &reg, Some(3.0), Some(&intc));
assert_eq!(intent(&t, &reg).unwrap().size, 3.33);
// BTC has five size decimals, so $10 at $76,000 is 0.00013 BTC.
let btc = reg.get("BTC").unwrap().clone();
let mut t = dollars("BTC", 10.0);
settle_size(&mut t, Some(76_000.0), Some(&btc));
settle_size(&mut t, &reg, Some(76_000.0), Some(&btc));
assert_eq!(intent(&t, &reg).unwrap().size, 0.00013);
// An amount under one step stays in dollars. It never rounds to zero or turns into units.
let mut t = dollars("BTC", 0.5);
let why = settle_size(&mut t, Some(76_000.0), Some(&btc)).expect("too small");
let why = settle_size(&mut t, &reg, Some(76_000.0), Some(&btc)).expect("too small");
assert!(why.contains("smallest BTC size") && t.dollars, "{why}");
assert!(intent(&t, &reg).is_err());
// A size typed in units is left alone.
let mut t = Ticket::new(2);
t.symbol = "BTC".into();
t.qty = "0.001".into();
assert_eq!(settle_size(&mut t, Some(76_000.0), Some(&btc)), None);
assert_eq!(settle_size(&mut t, &reg, Some(76_000.0), Some(&btc)), None);
assert_eq!((t.qty.as_str(), t.hl.from_dollars), ("0.001", None));
}
/// Greptile #37 (f1ff1b3): the ticket reads its market at the start of a frame, before an
/// edit to the market field, so a dollar amount was sized at the old market's mid. $10 at
/// a $2 mid is 5 units, worth $20 on a $4 market.
#[test]
fn a_dollar_amount_is_sized_for_the_market_the_ticket_names() {
use super::super::trading::Ticket;
let reg = registry();
let intc = reg.get("xyz:INTC").unwrap().clone();
let tsla = reg.get("xyz:TSLA").unwrap().clone();
let mut t = Ticket::new(1);
t.apply(&crate::command::TradeArgs {
buy: Some(true),
symbol: Some("xyz:INTC".into()),
notional: Some(10.0),
..Default::default()
});
settle_size(&mut t, &reg, Some(2.0), Some(&intc));
assert_eq!(t.qty, "5");
// The user changes the market, and the amount goes back to dollars.
t.symbol = "xyz:TSLA".into();
market_edited(&mut t);
assert_eq!((t.dollars, t.qty.as_str()), (true, "10"));
// This frame still holds INTC's market, read before the edit.
let note = settle_size(&mut t, &reg, Some(2.0), Some(&intc));
assert!(t.dollars, "sized $10 of TSLA at INTC's mid: {} units", t.qty);
assert!(note.is_some_and(|n| n.contains("$10")));
// The next frame reads TSLA's market: 10 / 400 is 0.025 (three size decimals).
assert_eq!(settle_size(&mut t, &reg, Some(400.0), Some(&tsla)), None);
assert_eq!(intent(&t, &reg).unwrap().size, 0.025);
// Half-typed, the market names nothing yet, so nothing is sized.
market_edited(&mut t);
t.symbol = "xyz:TS".into();
settle_size(&mut t, &reg, Some(400.0), Some(&tsla));
assert!(t.dollars);
}
#[test]
fn an_uncertain_order_blocks_until_its_window_passes() {
let u = Uncertain { cloid: tr_hl::actions::cloid(1), coin: "BTC".into(), sent: 1_000_000, asked: None };
+6 -8
View File
@@ -11,7 +11,7 @@ use tr_hl::rounding::to_wire;
use tr_hl::trader::{EXPIRES_AFTER_MS, OrderKind};
use super::TerminalApp;
use super::hl::{HlConfirm, HlKind, intent, is_hl_account, resolve_coin, settle_size};
use super::hl::{HlConfirm, HlKind, intent, is_hl_account, market_edited, resolve_coin, settle_size};
use super::portfolio_view::{money, price, qty};
use super::settings_view::SettingsCmd;
use super::trading::TicketAct;
@@ -129,11 +129,7 @@ impl TerminalApp {
);
if r.changed() {
t.error = None;
// If the size came from dollars, size the amount again for the new market.
if let Some(d) = t.hl.from_dollars.take() {
t.dollars = true;
t.qty = to_wire(d).unwrap_or_default();
}
market_edited(t);
}
match ctx.mid {
Some(m) => {
@@ -182,7 +178,7 @@ impl TerminalApp {
}
}
// `BUY xyz:AAPL $50` gets its size from the testnet mid. It is never 50 contracts.
unsized_note = settle_size(t, ctx.mid, asset.as_ref());
unsized_note = reg.as_ref().and_then(|r| settle_size(t, r, ctx.mid, asset.as_ref()));
if let (false, Some(m), Ok(q)) = (t.dollars, ctx.mid, t.qty.trim().parse::<f64>()) {
let from = t.hl.from_dollars.map_or(String::new(), |d| format!(" (from ${d}, at the testnet mid)"));
ui.label(RichText::new(format!("≈ {}{from}", money(q * m, "USD"))).font(mono(11.5)).color(muted()));
@@ -315,9 +311,11 @@ impl TerminalApp {
}
ui.separator();
// Value it with the market `intent` resolved now, not the one read before an edit.
let hl = &self.hl;
let planned = reg.as_ref().map(|r| {
intent(t, r).and_then(|i| {
tr_hl::trader::plan_order(r, &i, &ctx, &tr_hl::actions::cloid(1)).map_err(|e| e.to_string())
tr_hl::trader::plan_order(r, &i, &hl.ctx(&i.coin), &tr_hl::actions::cloid(1)).map_err(|e| e.to_string())
})
});
let row = |ui: &mut Ui, k: &str, v: String, strong: bool| {
+125 -11
View File
@@ -42,10 +42,10 @@ const FOLLOW_UP_MAX: Duration = Duration::from_millis(
);
/// When signed actions may run. Orders, modifies and leverage changes take turns, one at a
/// time in the order sent. Cancels and the dead man's switch never wait for them. KILL never
/// waits either. It cancels everything open at once, refuses any order confirmed before it
/// that hasn't been signed, even one still reading the market, and sweeps again once an
/// order that was already being sent can no longer land.
/// time in the order sent. Cancels never wait for them, and dead man's switch changes take
/// turns of their own. KILL never waits either. It cancels everything open at once, refuses
/// any order confirmed before it that hasn't been signed, even one still reading the market,
/// and sweeps again once an order that was already being sent can no longer land.
pub struct Turns {
turn: tokio::sync::Mutex<()>,
/// The newest kill switch seen. `place` refuses an order confirmed before it that isn't
@@ -55,6 +55,16 @@ pub struct Turns {
sending: std::sync::atomic::AtomicBool,
/// An order sent without a clear answer may still land on Hyperliquid until then (ms).
may_land_until: AtomicU64,
/// Dead man's switch changes go out one at a time.
schedule: tokio::sync::Mutex<Schedule>,
}
#[derive(Default)]
struct Schedule {
/// The app's id for the newest change sent.
newest: u64,
/// A change sent without a clear answer may still land until then (ms).
may_land_until: u64,
}
impl Default for Turns {
@@ -64,6 +74,7 @@ impl Default for Turns {
kills: tokio::sync::watch::Sender::new(0),
sending: Default::default(),
may_land_until: AtomicU64::new(0),
schedule: Default::default(),
}
}
}
@@ -133,12 +144,37 @@ impl Turns {
outcome
}
/// A cancel or the dead man's switch. These only take risk off, so they never wait for an
/// order. Hyperliquid accepts nonces that arrive out of order.
/// A cancel. It only takes risk off, so it never waits for an order. Hyperliquid accepts
/// nonces that arrive out of order.
pub async fn cancel(&self, go: impl Future<Output = Outcome>) -> Outcome {
go.await
}
/// A dead man's switch change (`scheduleCancel`), where `seq` is the app's id for it.
/// Changes go out one at a time, in the order the app made them, and never wait for an
/// order. The worker drops one that reaches it after a newer one went out (`None`).
/// After a change whose send got no clear answer, the next waits until that one can no
/// longer land, so an old clear never removes a newer schedule.
pub async fn schedule<GF>(&self, seq: u64, go: impl FnOnce() -> GF) -> Option<Outcome>
where
GF: Future<Output = (Outcome, Option<u64>)>,
{
let mut s = self.schedule.lock().await;
if seq <= s.newest {
return None;
}
s.newest = seq;
let wait = s.may_land_until.saturating_sub(now_ms());
if wait > 0 {
rt::sleep(Duration::from_millis(wait).min(FOLLOW_UP_MAX)).await;
}
let (outcome, may_land_until) = go().await;
if let Some(t) = may_land_until {
s.may_land_until = s.may_land_until.max(t);
}
Some(outcome)
}
/// The kill switch. `sweep` reads every open order and cancels it; `report` gets each
/// sweep's result. The first sweep runs at once. If an order was already being sent, KILL
/// takes the turn once that order has its answer, or once its `expiresAfter` has passed if
@@ -476,11 +512,11 @@ pub async fn handle(req: HlRequest, send: impl Fn(HlResponse) + Clone + 'static)
.await
}
HlAct::ScheduleCancel(at) => {
turns
.cancel(async {
send_signed(c, k, trader::sign_schedule_cancel(k, n, at, now_ms()).map(|s| vec![s])).await.0
})
.await
let go = || send_signed(c, k, trader::sign_schedule_cancel(k, n, at, now_ms()).map(|s| vec![s]));
match turns.schedule(id, go).await {
Some(outcome) => outcome,
None => return, // the app has since made a newer change
}
}
HlAct::CancelAll { registry } => {
return turns.kill(kill_epoch, || kill_all(c, k, n, &user, &registry), done).await;
@@ -828,6 +864,84 @@ mod tests {
assert_eq!(o.expect("a cancel waited behind an order"), ok());
}
/// Greptile #37 (f1ff1b3): dead man's switch changes ran side by side. Turning the switch
/// off and on again while the clear was slow let the new schedule go out first, and the
/// old clear could then land after it and remove it.
#[tokio::test]
async fn dead_mans_switch_changes_go_out_in_the_order_they_were_made() {
let turns = Arc::new(Turns::default());
// An order stuck reading the market holds the order turn. Schedule changes don't wait.
let _order = tokio::spawn({
let turns = turns.clone();
async move {
let read = std::future::pending::<Result<(), String>>();
turns.place(Some(0), read, |()| async { (ok(), None) }).await
}
});
tokio::time::sleep(Duration::from_millis(20)).await;
let sent = Arc::new(Mutex::new(Vec::new()));
let (answer, answered) = tokio::sync::oneshot::channel::<()>();
let clear = tokio::spawn({
let (turns, sent) = (turns.clone(), sent.clone());
async move {
let go = || async move {
sent.lock().unwrap().push("clear");
let _ = answered.await; // the answer is slow
(ok(), None)
};
turns.schedule(1, go).await
}
});
eventually("the clear went out without waiting for the order", || !sent.lock().unwrap().is_empty()).await;
let set = tokio::spawn({
let (turns, sent) = (turns.clone(), sent.clone());
async move {
let go = || async move {
sent.lock().unwrap().push("set");
(ok(), None)
};
turns.schedule(2, go).await
}
});
tokio::time::sleep(Duration::from_millis(50)).await;
assert_eq!(*sent.lock().unwrap(), ["clear"], "the new schedule went out before the clear was answered");
answer.send(()).unwrap();
assert_eq!(tokio::time::timeout(QUICK, clear).await.unwrap().unwrap(), Some(ok()));
assert_eq!(tokio::time::timeout(QUICK, set).await.unwrap().unwrap(), Some(ok()));
assert_eq!(*sent.lock().unwrap(), ["clear", "set"]);
}
/// A change with no clear answer may still land until its `expiresAfter` passes, so the
/// next one waits until then.
#[tokio::test]
async fn after_a_change_with_no_clear_answer_the_next_waits_until_it_cannot_land() {
let turns = Turns::default();
let window = 300;
let until = now_ms() + window;
let clear = turns
.schedule(1, || async move { (Outcome::Failed(SendError::Uncertain("timed out".into())), Some(until)) })
.await;
assert!(matches!(clear, Some(Outcome::Failed(SendError::Uncertain(_)))));
let t0 = std::time::Instant::now();
assert_eq!(turns.schedule(2, || async { (ok(), None) }).await, Some(ok()));
assert!(t0.elapsed() >= Duration::from_millis(window - 20), "sent while the clear could still land");
}
/// The worker runs requests side by side, so an older change can reach it after a newer
/// one went out. The worker drops it and never sends it.
#[tokio::test]
async fn a_dead_mans_switch_change_the_app_has_replaced_is_dropped() {
let turns = Turns::default();
assert_eq!(turns.schedule(5, || async { (ok(), None) }).await, Some(ok()));
let sent = AtomicBool::new(false);
let go = || async {
sent.store(true, Ordering::SeqCst);
(ok(), None)
};
assert_eq!(turns.schedule(4, go).await, None);
assert!(!sent.load(Ordering::SeqCst));
}
/// An order confirmed before KILL is never signed; one confirmed after it goes ahead.
#[tokio::test]
async fn an_order_confirmed_before_kill_is_never_signed() {
+1 -1
View File
@@ -264,7 +264,7 @@ The rules from `server.md` §7 carry over to trading [repo]:
| Confirmation | One per order, cancel-all and leverage change (§6.3). Enter confirms; Escape or a click outside cancels, as today [repo]. |
| Hard caps | Constants in `tr-hl`, not settings: **$100** per order (size × the higher of the order price and the mid), **3×** leverage, **1%** market slippage. `tr-hl` checks them when the confirm opens, and the worker reads the testnet mid and leverage again and re-checks right before signing. An order is refused while the account's leverage on that market is over 3× (Hyperliquid's default is 20×), so a confirmed "Set 3×" comes first; reduce-only orders are not blocked by leverage. A breach refuses before anything is signed. |
| Kill switch | `KILL` or the red KILL button in `ORD` (and in Settings): one confirmation, then Hyperliquid orders turn off in prefs and every open order is cancelled, one `cancel` action per dex (cancels have a higher rate-limit allowance [doc: RL]). KILL never waits for another request. It cancels at once and refuses any order confirmed before it that hasn't been signed, even one still reading the market. If an order was already being sent, KILL cancels again once that order can no longer land. That is when its answer comes, or when its `expiresAfter` has passed if the send got no clear answer. Single cancels and the dead man's switch never wait for orders either. "And flatten" is not in v1. |
| Dead man's switch | **Opt-in, off by default** (Q7). While on and trading is enabled, Terminal keeps a `scheduleCancel` about 60 s ahead and refreshes it every 30 s [doc: EXCH]; turning it off clears the schedule. Constraints: at least 5 s ahead, and at most 10 triggers per day, reset 00:00 UTC [doc: EXCH]. |
| Dead man's switch | **Opt-in, off by default** (Q7). While on and trading is enabled, Terminal keeps a `scheduleCancel` about 60 s ahead and refreshes it every 30 s [doc: EXCH]; turning it off clears the schedule. These changes go out one at a time, in the order Terminal made them, and never wait for an order. After one whose send got no clear answer, the next waits until it can no longer land, so a late clear never removes a newer schedule. Constraints: at least 5 s ahead, and at most 10 triggers per day, reset 00:00 UTC [doc: EXCH]. |
| Nonces | One agent per device and network (nonces are tracked per signer; "use a separate private key" per session [doc: NONCE]). The nonce is an atomic `max(last + 1, now_ms)`, within (T − 2 days, T + 1 day) [doc: NONCE]. A noop (`{"type": "noop"}`) can invalidate an in-flight nonce [doc: EXCH]. |
| Uncertain results | A network error after send means "uncertain", never "failed". Reconcile by `cloid` via `orderStatus` and `orderUpdates` before allowing a resend (same idea as `PlaceError::uncertain` [repo]). Every `/info` and `/exchange` request has its own 10 s deadline, in the browser too (`fetch` has no default timeout); an `/exchange` request that runs out of time is uncertain. |
| Never log keys | `AgentKey` is a newtype with redacted `Debug`/`Display` (like `Credentials` and `mask()` [repo]) and is zeroized on drop. It is never serialized into prefs, layouts or the demo JSON. Signed request bodies are not logged; logs carry `cloid`, `oid`, asset and status only. Unit test: a `tracing` capture of a full place/cancel cycle contains no 64-hex-digit secrets. |