From 312971dd898b843eb9a148f10ecc3bfe58b8626f Mon Sep 17 00:00:00 2001 From: piekstra Date: Tue, 11 Aug 2026 15:57:05 -0400 Subject: [PATCH] Show the recent trade window, not just the last print MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The market rail vetoes an order by comparing it to a reference built from the book, the feed, and the last print. On a thin book the last print can be days old and far from where the property actually changes hands, so a single stale outlier silently dominates two healthy live quotes — and nothing in the output let the operator tell an outlier from a genuine repricing. Observed live: a bid was refused as "6.3% above the market reference" where that reference was one print from three days earlier. The orderbook and the feed both quoted materially higher, and the median of recent prints sat between them. The rail was arguably right to ask, but it showed only the number that made it object. Adds to the diagnostic block: * median of the recent window, and its low/high range * the AGE of the last print The window is selected by timestamp rather than array order; the payload is not guaranteed sorted, and taking the first N would summarise arbitrary prints. Trigger semantics are unchanged — this is about giving a human enough to judge, not about loosening the check. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/commands/orders.rs | 136 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 136 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 53d29f8..978d0e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,7 +1148,7 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lofty-cli" -version = "0.1.7" +version = "0.1.8" dependencies = [ "assert_cmd", "clap", diff --git a/Cargo.toml b/Cargo.toml index 8bbb544..b08448e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lofty-cli" -version = "0.1.7" +version = "0.1.8" edition = "2021" rust-version = "1.88" description = "Interact with the Lofty (lofty.ai) fractional real-estate marketplace API — market data, order books, and market-making analysis. Keychain-secured, agent-friendly." diff --git a/src/commands/orders.rs b/src/commands/orders.rs index 0283abb..906b1cc 100644 --- a/src/commands/orders.rs +++ b/src/commands/orders.rs @@ -210,11 +210,39 @@ struct MarketView { feed_bid: Option, feed_ask: Option, last_trade: Option, + /// When the last print happened (epoch ms). A single stale print can dominate + /// the reference and veto a sane order, so its AGE is part of the evidence. + last_trade_at: Option, + /// Median of the most recent prints. One print is an anecdote; the median is + /// what the property actually changes hands at, and the two can diverge + /// sharply on thin books. + median_recent: Option, + /// Low/high of that same recent window — shows whether the median is a tight + /// consensus or the midpoint of a wide scatter. + recent_low_high: Option<(f64, f64)>, /// Trades seen at all. An empty tape means the price is not discoverable /// here, which is a reason to stop rather than to shrug. trade_count: usize, } +/// How many recent prints summarise "where this actually trades". +const RECENT_WINDOW: usize = 20; + +/// Median of a price sample. Returns None for an empty sample. +fn median(xs: &[f64]) -> Option { + if xs.is_empty() { + return None; + } + let mut v = xs.to_vec(); + v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let n = v.len(); + Some(if n % 2 == 1 { + v[n / 2] + } else { + (v[n / 2 - 1] + v[n / 2]) / 2.0 + }) +} + /// Tolerance before a price counts as "materially worse than the market". Wide /// enough that ordinary spread does not trip it, tight enough that a fat-finger /// or a stale-endpoint read does. @@ -291,8 +319,26 @@ fn market_view(book: Option<&Value>, trades: Option<&Value>) -> MarketView { .collect() }) .unwrap_or_default(); - let last_trade = recent.iter().max_by_key(|(_, t)| *t).map(|(p, _)| *p); + let newest = recent.iter().max_by_key(|(_, t)| *t); + let last_trade = newest.map(|(p, _)| *p); + let last_trade_at = newest.map(|(_, t)| *t).filter(|t| *t > 0); + // Summarise the recent window by TIME, not by array order — the payload is + // not guaranteed sorted, and using its order silently mixes old prints in. + let mut by_time: Vec<(f64, u64)> = recent.clone(); + by_time.sort_by_key(|(_, t)| std::cmp::Reverse(*t)); + let window: Vec = by_time + .iter() + .take(RECENT_WINDOW) + .map(|(p, _)| *p) + .collect(); + let median_recent = median(&window); + let recent_low_high = window.iter().fold(None, |acc: Option<(f64, f64)>, p| { + Some(acc.map_or((*p, *p), |(lo, hi)| (lo.min(*p), hi.max(*p)))) + }); MarketView { + last_trade_at, + median_recent, + recent_low_high, book_bid: side(book, "bids", "buyOrders", true), book_ask: side(book, "asks", "sellOrders", false), feed_bid: trades @@ -386,14 +432,43 @@ impl MarketView { /// the failure mode, so hiding it behind one number would repeat it. fn describe(&self) -> String { let f = |o: Option| o.map_or("—".into(), |v| format!("${v:.2}")); + let age = self + .last_trade_at + .and_then(|t| { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_millis() as u64) + .and_then(|now| now.checked_sub(t)) + }) + .map(|ms| { + let days = ms as f64 / 86_400_000.0; + if days >= 1.0 { + format!(", {days:.0}d ago") + } else { + format!(", {:.0}h ago", days * 24.0) + } + }) + .unwrap_or_default(); + // The recent window is the point of this block: a lone stale print can + // veto a sane order, and only the median plus the age reveal that. + let recent = match (self.median_recent, self.recent_low_high) { + (Some(m), Some((lo, hi))) => format!( + "\n recent median ${m:.2} range ${lo:.2}–${hi:.2} (last {} print(s))", + self.trade_count.min(RECENT_WINDOW), + ), + _ => String::new(), + }; format!( - " orderbook bid {} ask {}\n trades feed bid {} ask {}\n last trade {} ({} recent print(s))", + " orderbook bid {} ask {}\n trades feed bid {} ask {}\n last trade {}{} ({} recent print(s)){}", f(self.book_bid), f(self.book_ask), f(self.feed_bid), f(self.feed_ask), f(self.last_trade), + age, self.trade_count, + recent, ) } @@ -542,4 +617,61 @@ mod tests { assert!(min_contracts_note_from(&json!({}), "01PROPA", 1).is_none()); assert!(min_contracts_note_from(&json!({"programs": []}), "01PROPA", 1).is_none()); } + + #[test] + fn median_handles_odd_even_and_empty() { + assert_eq!(median(&[]), None); + assert_eq!(median(&[5.0]), Some(5.0)); + assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0)); + assert_eq!(median(&[4.0, 1.0, 3.0, 2.0]), Some(2.5)); + } + + #[test] + fn describe_surfaces_the_recent_window_not_just_the_last_print() { + // The live case that motivated this: two healthy quotes vetoed by a single + // stale outlier. The last print was $59.26 while the recent median was + // ~$61, and nothing in the output said so — so the operator could not tell + // an outlier from a genuine repricing. + let b = book(vec![63.55], vec![65.00]); + let t = feed( + 63.66, + 63.99, + vec![ + (59.26, 9_000), + (61.02, 8_000), + (60.92, 7_000), + (63.20, 6_000), + (61.02, 5_000), + ], + ); + let d = market_view(Some(&b), Some(&t)).describe(); + assert!(d.contains("median"), "{d}"); + assert!( + d.contains("$61.02"), + "median of the window should show: {d}" + ); + assert!(d.contains("range"), "{d}"); + assert!( + d.contains("$59.26") && d.contains("$63.20"), + "range ends: {d}" + ); + } + + #[test] + fn the_recent_window_is_chosen_by_time_not_array_order() { + // The payload is not guaranteed sorted. Taking the first N in array order + // would summarise arbitrary prints and quietly mislead. + let b = book(vec![50.0], vec![55.0]); + let mut prints: Vec<(f64, u64)> = (0..30).map(|i| (10.0, i as u64)).collect(); + // Newest prints, appended LAST, all at a different price. + prints.extend((100..120).map(|i| (90.0, i as u64))); + let t = feed(52.0, 53.0, prints); + let m = market_view(Some(&b), Some(&t)); + assert_eq!( + m.median_recent, + Some(90.0), + "must summarise the NEWEST prints" + ); + assert_eq!(m.last_trade, Some(90.0)); + } }