Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/moon-core/src/feed/live/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 8 additions & 3 deletions crates/moon-core/src/feed/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 59 additions & 5 deletions crates/moon-core/src/feed/trade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)),
);
}

Expand Down
37 changes: 37 additions & 0 deletions crates/moon-core/src/feed/trade/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
2 changes: 0 additions & 2 deletions crates/moon-core/src/market/source/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -745,7 +745,6 @@ pub enum LatestPriceError {
NoProvider,
NoClient,
NoSnapshot,
NoHistoryReaders,
NoPrice,
}

Expand All @@ -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"),
}
}
Expand Down
38 changes: 20 additions & 18 deletions crates/moon-core/src/market/source/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
Expand Down
15 changes: 12 additions & 3 deletions crates/moon-core/src/session/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
}
Expand Down
Loading