diff --git a/crates/moon-core/src/feed/live/commands.rs b/crates/moon-core/src/feed/live/commands.rs index 486417ad..76bf1739 100644 --- a/crates/moon-core/src/feed/live/commands.rs +++ b/crates/moon-core/src/feed/live/commands.rs @@ -883,8 +883,8 @@ pub(super) fn drain_commands( Ok(CoreCmd::MarketSellPosition { market }) => { trade::market_sell_position(client, server.id, market); } - Ok(CoreCmd::MarketSellToken { market, size }) => { - trade::market_sell_token(client, server.id, market, size); + Ok(CoreCmd::MarketSellToken { market, qty, price }) => { + trade::market_sell_token(client, server.id, market, qty, price); } Ok(CoreCmd::CancelMarketBuys { market }) => { trade::cancel_market_buys(client, server.id, &market); diff --git a/crates/moon-core/src/feed/mod.rs b/crates/moon-core/src/feed/mod.rs index 7ae947c4..45b1edfb 100644 --- a/crates/moon-core/src/feed/mod.rs +++ b/crates/moon-core/src/feed/mod.rs @@ -476,9 +476,14 @@ pub enum CoreCmd { /// (`TDoClosePositionCommand`). This performs a live exchange action. MarketSellPosition { market: String }, /// Sell a market's spot token at market from the Market Sell button on an Assets holding row. - /// This uses moonproto `trade().sell_order(SellOrderParams{market, price:0=market, size})` - /// (`TDoSellOrderCommand`) and performs a live exchange action. - MarketSellToken { market: String, size: f64 }, + /// This uses moonproto `trade().sell_order(SellOrderParams)` (`TDoSellOrderCommand`), whose + /// size field carries the account's balance currency rather than the coin — the feed converts + /// `qty` with `price`, so both must describe the same market. This is a live exchange action. + MarketSellToken { + market: String, + qty: f64, + price: f64, + }, /// Cancel pending buy orders for a market from the Cancel Buy button. The feed reads the /// retained snapshot, selects the market's pre-fill buy-phase orders in `OS_None` or `BuySet`, /// and sends `orders().cancel(uid)` for each. This is a live action. diff --git a/crates/moon-core/src/feed/trade.rs b/crates/moon-core/src/feed/trade.rs index 6443f143..bf651175 100644 --- a/crates/moon-core/src/feed/trade.rs +++ b/crates/moon-core/src/feed/trade.rs @@ -163,16 +163,70 @@ pub(super) fn market_sell_position(client: &MoonClient, server_id: u64, market: ); } +/// Fraction of the market price a spot `Market sell` prices its limit order at. +/// +/// Ported from the core's own sale on the 2026-09-03 BinKEAUSDC run: last ask 78528.68 priced the +/// order at 62822.94 — exactly `* 0.8` — and it filled at 78528.67. A venue that refuses a price +/// this far out corrects it itself: Bitget answered `25205 trading price cannot be below 2%` and +/// the core retried at its own bound, filling on the second try (2026-09-03 AERO). +const MARKET_SELL_PRICE_FACTOR: f64 = 0.8; + +/// Wire terms for a spot market sale: `(limit price, order size)`. +/// +/// The size is the COIN QUANTITY, and the only field that was ever wrong here is the price. +/// Measured on Bitget, 2026-09-03: a sale sent as `size=103.79, price=0.0035616` came back as +/// `Sell: 103.8 MANTRA` and was rejected for `less than the minimum amount 1 USDT` (103.78 coins +/// ≈ $0.37) instead of selling the 29141.29 held; a second, `size=79.679`, sold exactly +/// `79.67 AERO` out of 198.01. The core takes the number as coins and rounds it to the lot step. +/// +/// Do NOT carry `NewOrderParams::size` semantics over: an OPENING order says how much balance +/// currency to spend, while this command says how much coin to sell. They are different commands. +/// +/// The price is what breaks a spot Market Sell: `TDoSellOrderCommand` has no market-order flag +/// (unlike `ClosePosition`, which carries a real `market_sell` bool), and `price=0` is not a +/// market order. Sent with a zero, a 0.005 BTC holding reached Binance as `quantity=499.99999` — +/// `0.005 / 1e-5`, the lot step the core substituted for the zero — and NOTIONAL rejected it five +/// times (2026-09-03 BinKEAUSDC log). Moonbot's own Market Sell instead prices a LIMIT order far +/// enough through the book to fill like a market one, which is what this reproduces. +/// +/// Args: +/// qty: Coin quantity being sold, which rides as the order size unchanged. +/// price: Last market price in the market's quote currency. +/// +/// Returns: +/// `None` unless both inputs and the limit they produce are finite and positive — the zero +/// price is exactly the input that produced the runaway quantity above. +fn market_sell_terms(qty: f64, price: f64) -> Option<(f64, f64)> { + if !qty.is_finite() || qty <= 0.0 || !price.is_finite() || price <= 0.0 { + return None; + } + let limit = price * MARKET_SELL_PRICE_FACTOR; + (limit.is_finite() && limit > 0.0).then_some((limit, qty)) +} + /// Sells a market's SPOT TOKEN at market (`TDoSellOrderCommand`), as triggered by the Assets -/// holding row's `Market sell` button. `price=0` means a market order; `size` is the base-coin -/// amount. -pub(super) fn market_sell_token(client: &MoonClient, server_id: u64, market: String, size: f64) { +/// holding row's `Market sell` button. See [`market_sell_terms`] for what actually rides in the +/// two numeric fields; terms that cannot be built send nothing at all. +pub(super) fn market_sell_token( + client: &MoonClient, + server_id: u64, + market: String, + qty: f64, + price: f64, +) { + let Some((limit, size)) = market_sell_terms(qty, price) else { + log::warn!( + "core {} market sell token {market}: qty={qty} price={price} yields no sendable order terms, nothing sent", + crate::feed::core_label(server_id) + ); + return; + }; report( server_id, - format!("market sell token {market} size={size}"), + format!("market sell token {market} qty={qty} size={size} limit={limit}"), client .trade() - .sell_order(SellOrderParams::new(market, 0.0, size)), + .sell_order(SellOrderParams::new(market, limit, size)), ); } diff --git a/crates/moon-core/src/feed/trade/tests.rs b/crates/moon-core/src/feed/trade/tests.rs index 4eca745d..59c461ea 100644 --- a/crates/moon-core/src/feed/trade/tests.rs +++ b/crates/moon-core/src/feed/trade/tests.rs @@ -94,3 +94,40 @@ fn refuses_when_no_active_sell_order() { SplitTarget::Ambiguous ); } + +/// The order size IS the coin quantity; only the price is derived. +/// +/// Regression target, measured on Bitget 2026-09-03: sizing the order in the account's balance +/// currency instead sent `size=79.679` for a 198.01 AERO holding, and the core sold exactly +/// 79.67 AERO — the number taken as coins and snapped to the lot step. The same mistake on +/// MANTRA offered $0.37 of a $103 holding and was rejected for the venue's 1 USDT minimum. +#[test] +fn a_spot_market_sale_sends_the_coin_quantity_as_the_size() { + let (limit, size) = market_sell_terms(198.01, 0.5031).expect("a priced holding has terms"); + + assert_eq!( + size, 198.01, + "the whole held quantity must ride as the size" + ); + // Ported from the core's own sale, which priced at exactly `last * 0.8` and filled at market. + assert!((limit - 0.40248).abs() < 1e-9, "limit price was {limit}"); + assert!(limit < 0.5031, "a sell must price THROUGH the book to fill"); +} + +/// Inputs that cannot produce an order size send nothing, rather than a zero the core reinterprets. +/// +/// Mutation: restore the old `price=0` call. A zero price is precisely the input whose fallback +/// inside the core produced the runaway quantity, so it must not reach the wire. +#[test] +fn unpriced_or_empty_holdings_yield_no_sell_terms() { + assert!(market_sell_terms(0.005, 0.0).is_none()); + assert!(market_sell_terms(0.005, -1.0).is_none()); + assert!(market_sell_terms(0.005, f64::NAN).is_none()); + assert!(market_sell_terms(0.005, f64::INFINITY).is_none()); + assert!(market_sell_terms(0.0, 78_528.68).is_none()); + assert!(market_sell_terms(-0.005, 78_528.68).is_none()); + assert!(market_sell_terms(f64::NAN, 78_528.68).is_none()); + // A finite price cannot overflow the limit, which only ever scales it DOWN, so an absurd but + // finite pair is not a refusal — the venue's own filters are what reject it. + assert!(market_sell_terms(f64::MAX, f64::MAX).is_some()); +} diff --git a/crates/moon-core/src/market/source/mod.rs b/crates/moon-core/src/market/source/mod.rs index 762b6003..777c6f7a 100644 --- a/crates/moon-core/src/market/source/mod.rs +++ b/crates/moon-core/src/market/source/mod.rs @@ -745,7 +745,6 @@ pub enum LatestPriceError { NoProvider, NoClient, NoSnapshot, - NoHistoryReaders, NoPrice, } @@ -755,7 +754,6 @@ impl std::fmt::Display for LatestPriceError { Self::NoProvider => f.write_str("no provider"), Self::NoClient => f.write_str("no client"), Self::NoSnapshot => f.write_str("no snapshot"), - Self::NoHistoryReaders => f.write_str("no history readers"), Self::NoPrice => f.write_str("no price"), } } diff --git a/crates/moon-core/src/market/source/read.rs b/crates/moon-core/src/market/source/read.rs index 90100060..702bcc29 100644 --- a/crates/moon-core/src/market/source/read.rs +++ b/crates/moon-core/src/market/source/read.rs @@ -203,27 +203,29 @@ impl MarketDataSource { let snapshot = client .snapshot_versioned() .ok_or(LatestPriceError::NoSnapshot)?; - let readers = snapshot - .market_history_readers(market) - .ok_or(LatestPriceError::NoHistoryReaders)?; - - let mut trades = Vec::new(); - if let Some(reader) = readers.futures_trades.or(readers.spot_trades) { - reader.copy_last(1, &mut trades); - if let Some(row) = trades.last() { - if row.price.is_finite() && row.price > 0.0 { - return Ok(row.price); + // History is an OPTIMIZATION here, not a requirement: moonproto builds a history store only + // for markets inside the client's `TradeStorageScope`, so demanding readers used to fail + // outright on a market whose `p_last` the snapshot holds — with the fallback below sitting + // unreachable underneath. Every caller wants the last price, not the store it came from. + if let Some(readers) = snapshot.market_history_readers(market) { + let mut trades = Vec::new(); + if let Some(reader) = readers.futures_trades.or(readers.spot_trades) { + reader.copy_last(1, &mut trades); + if let Some(row) = trades.last() { + if row.price.is_finite() && row.price > 0.0 { + return Ok(row.price); + } } } - } - let mut last_prices = Vec::new(); - if let Some(reader) = readers.last_prices { - reader.copy_last(1, &mut last_prices); - if let Some(row) = last_prices.last() { - let price = row.price(); - if price.is_finite() && price > 0.0 { - return Ok(price); + let mut last_prices = Vec::new(); + if let Some(reader) = readers.last_prices { + reader.copy_last(1, &mut last_prices); + if let Some(row) = last_prices.last() { + let price = row.price(); + if price.is_finite() && price > 0.0 { + return Ok(price); + } } } } diff --git a/crates/moon-core/src/session/commands.rs b/crates/moon-core/src/session/commands.rs index a8a5d518..4e117ece 100644 --- a/crates/moon-core/src/session/commands.rs +++ b/crates/moon-core/src/session/commands.rs @@ -419,11 +419,20 @@ impl SessionManager { } /// Sell a core market's spot token at market from the Market Sell button on a holding row. - /// `size` is the quantity in the base coin, usually the full balance. - pub fn market_sell_token(&self, core: CoreId, market: String, size: f64) -> Result<()> { + /// + /// `qty` is the coin quantity, usually the full balance, and `price` the market's own last + /// price: the wire carries the order size in the account's balance currency, so the feed + /// multiplies the two. Passing a price from another market silently resizes the order. + pub fn market_sell_token( + &self, + core: CoreId, + market: String, + qty: f64, + price: f64, + ) -> Result<()> { self.send_core_cmd( core, - CoreCmd::MarketSellToken { market, size }, + CoreCmd::MarketSellToken { market, qty, price }, "market sell token", ) } diff --git a/crates/moon-ui-gpui/src/panels/assets/table.rs b/crates/moon-ui-gpui/src/panels/assets/table.rs index 410a9326..cb8d6041 100644 --- a/crates/moon-ui-gpui/src/panels/assets/table.rs +++ b/crates/moon-ui-gpui/src/panels/assets/table.rs @@ -1068,12 +1068,12 @@ fn actions_cell( ) -> MoonDataCell { // An open position or positive spot balance is sellable only when its `` market // actually exists on the core. For example, a USDC account may have no USDTUSDC market. - let size = if e.row.qty.abs() > 0.0 { + let qty = if e.row.qty.abs() > 0.0 { e.row.qty.abs() } else { e.row.qty_full.abs() }; - let sellable = is_position || size > 0.0; + let sellable = is_position || qty > 0.0; if !sellable || e.row.market.is_empty() || !e.market_exists { return MoonDataCell::text(String::new()); } @@ -1084,6 +1084,9 @@ fn actions_cell( let view_ms = view.clone(); let market_ms = market.clone(); let coin_ms = e.row.coin.clone(); + // A position closes through its own command and carries no quantity; a spot holding sells this + // one, which rides as the order size unchanged. + let spot_qty = (!is_position).then_some(qty); let el = h_flex() .w_full() .h_full() @@ -1102,8 +1105,7 @@ fn actions_cell( view_ms.clone(), core, market_ms.clone(), - is_position, - size, + spot_qty, coin_ms.clone(), window, app, @@ -1128,6 +1130,25 @@ fn actions_cell( MoonDataCell::element(el) } +/// Why a confirmed Market Sell was not sent, so the window can name the guard that stopped it. +enum MarketSellRefusal { + /// Navigation removed the captured core from the panel's live scope. + ScopeChanged, + /// No live price, and `TDoSellOrderCommand` has no market-order flag to send instead: a + /// priceless order is the very input that made the core invent a quantity of its own. + NoPrice, +} + +impl MarketSellRefusal { + /// The localized warning shown to whoever pressed Yes. + fn message(&self) -> String { + match self { + Self::ScopeChanged => t!("assets.market_sell_scope_changed").to_string(), + Self::NoPrice => t!("assets.market_sell_no_price").to_string(), + } + } +} + /// Decide whether a confirmation captured for one core still has dispatch authority. /// /// Args: @@ -1150,27 +1171,26 @@ fn market_sell_core_is_authorized( /// /// Only the Yes button submits the irreversible action: `market_sell_position` closes a position, /// while `market_sell_token` sells a spot balance. A group-owned dialog revalidates its captured -/// core against the current effective workspace scope immediately before either command. +/// core against the current effective workspace scope immediately before either command, and a +/// spot sale additionally reads a live price, without which the core invents a quantity. /// /// Args: /// view: Assets entity retaining host scope and Backend authority. /// core: Core captured from the rendered row. /// market: Resolved market submitted on confirmation. -/// is_position: Whether to close a position instead of selling a spot balance. -/// size: Spot quantity used only when `is_position` is false. +/// spot_qty: Coin quantity of a spot holding, sent as the order size; `None` closes a +/// position instead. /// coin: Display token interpolated into the confirmation question. /// window: Window that owns the unique dialog and refusal notification. /// app: Application context used to build the dialog. /// /// Returns: -/// Nothing; stale group authority closes with a visible warning and sends no command. -#[allow(clippy::too_many_arguments)] +/// Nothing; every refusal closes with a visible warning and sends no command. fn open_market_sell_confirm( view: Entity, core: CoreId, market: String, - is_position: bool, - size: f64, + spot_qty: Option, coin: String, window: &mut Window, app: &mut App, @@ -1232,7 +1252,7 @@ fn open_market_sell_confirm( .variant(MoonButtonVariant::Danger) .label(format!(" {} ", t!("dialogs.yes"))) .on_click(move |_, window, cx| { - let authorized = confirm_view.update(cx, |this, cx| { + let refusal = confirm_view.update(cx, |this, cx| { let b = this.backend.read(cx); let effective_scope = this.effective_scope(b); if !market_sell_core_is_authorized( @@ -1240,17 +1260,37 @@ fn open_market_sell_confirm( effective_scope.as_ref().map(|scope| scope.ids()), core, ) { - return false; + return Some(MarketSellRefusal::ScopeChanged); } - // Close a position at market, or sell the remaining spot token. - let res = if is_position { - b.session.market_sell_position(core, market_c.clone()) - } else { - b.session.market_sell_token( - core, - market_c.clone(), - size, - ) + // Close a position at market, or sell the remaining spot + // token. The price guard runs here rather than in the + // feed: only this side can say why nothing was sent. + let res = match spot_qty { + None => b + .session + .market_sell_position(core, market_c.clone()), + Some(qty) => { + // The LIVE quote-denominated price, not the one + // rendered into the row: a wallet-derived row + // prices in USDT, and a confirmation left open + // goes stale against the book it must cross. + let price = b + .session + .market_source() + .latest_price(core, &market_c) + .ok() + .map(f64::from) + .filter(|p| p.is_finite() && *p > 0.0); + let Some(price) = price else { + return Some(MarketSellRefusal::NoPrice); + }; + b.session.market_sell_token( + core, + market_c.clone(), + qty, + price, + ) + } }; if let Err(err) = res { log::warn!( @@ -1258,13 +1298,11 @@ fn open_market_sell_confirm( ); } cx.notify(); - true + None }); - if !authorized { + if let Some(refusal) = refusal { window.push_notification( - MoonNotification::warning( - t!("assets.market_sell_scope_changed").to_string(), - ), + MoonNotification::warning(refusal.message()), cx, ); } diff --git a/crates/moon-ui-gpui/src/panels/assets/table/tests.rs b/crates/moon-ui-gpui/src/panels/assets/table/tests.rs index 7766a5b6..c13f0198 100644 --- a/crates/moon-ui-gpui/src/panels/assets/table/tests.rs +++ b/crates/moon-ui-gpui/src/panels/assets/table/tests.rs @@ -74,12 +74,41 @@ fn market_sell_yes_revalidates_scope_before_dispatch() { let authority = callback .find("market_sell_core_is_authorized(") .expect("Yes must validate the captured core"); + // Matched WITHOUT the `b.session` prefix: rustfmt breaks a deeply indented call across lines, + // and this test pins dispatch ORDER, not the receiver's formatting. let position = callback - .find("b.session.market_sell_position(") + .find(".market_sell_position(") .expect("position sell command must remain reachable"); let token = callback - .find("b.session.market_sell_token(") + .find(".market_sell_token(") .expect("token sell command must remain reachable"); assert!(scope_read < authority && authority < position && authority < token); } + +/// A spot sale must send a live price it read at dispatch, never a zero and never the rendered one. +/// +/// Mutation: go back to `price=0`, or carry `e.row.price` into the confirmation. The zero is what +/// made the core invent `quantity=499.99999` for a 0.005 BTC holding, and a wallet-derived row +/// prices in USDT rather than in the market's quote (2026-09-03). +#[test] +fn a_spot_market_sell_reads_a_live_price_before_dispatch() { + let source = include_str!("../table.rs"); + let callback = source + .split_once("MoonButton::new(\"assets-msell-yes\")") + .expect("Market Sell Yes callback must exist") + .1; + let live = callback + .find(".latest_price(core, &market_c)") + .expect("Yes must re-read the live quote price"); + let token = callback + .find(".market_sell_token(") + .expect("token sell command must remain reachable"); + + assert!(live < token, "the live price must be read before dispatch"); + let call = &callback[token..]; + assert!( + call.contains("qty, price)") || call.contains("qty,\n") && call.contains("price,"), + "the token sale must carry the held quantity and the freshly read price" + ); +} diff --git a/locales/assets.yml b/locales/assets.yml index 86eb978d..49caa371 100644 --- a/locales/assets.yml +++ b/locales/assets.yml @@ -171,6 +171,10 @@ assets.market_sell_confirm: ru: "Продажа по маркету" en: "Market sell" es: "Venta a mercado" +assets.market_sell_no_price: + ru: "Продажа отменена: у рынка нет текущей цены, а без неё ордер выставить нельзя." + en: "Sale cancelled: the market has no current price, and the order needs one." + es: "Venta cancelada: el mercado no tiene precio actual y la orden lo necesita." assets.market_sell_q: ru: "Точно продать %{coin} по маркету?" en: "Really market-sell %{coin}?"