From 560182b5f213679c52c76e9b18d135408dc8568c Mon Sep 17 00:00:00 2001 From: piekstra Date: Fri, 2 Oct 2026 10:21:41 -0400 Subject: [PATCH 1/3] feat(orders): competitiveness report for your open orders `lofty orders competitiveness` (alias `comp`) reports, per property and side, how far your resting orders are from a fill and from the competition: - Buy: gap from your highest bid up to the lowest ask not yours (how far a seller must come down, % of that ask), and between the book's highest bid and the highest bid not yours. - Sell: gap from the highest bid not yours up to your lowest ask, and between the book's lowest ask and the lowest ask not yours. - `leadUsd` gives your distance ahead of (or behind) the best competing order. The public book is aggregated by price without order ids, so your share of a level is your open quantity at that price; the legacy per-order envelope is matched by order id. Partially filled orders count at their remaining quantity. Read-only; `--json` emits `orders-competitiveness/v1`. Also fixes `orders list --property-id`: it used the `?propertyId=` query, which reports a partially filled order's original quantity. It now filters the `all=true` list client-side, which reports what remains (the same approach `quote` already takes). --- AGENTS.md | 5 + README.md | 31 ++ src/commands/competitiveness.rs | 437 ++++++++++++++++++ src/commands/mod.rs | 1 + src/commands/orders.rs | 67 ++- tests/cli_surface.rs | 14 + tests/fixture_shapes.rs | 40 ++ tests/fixtures/README.md | 8 + .../orderbook-alone-on-top.json | 33 ++ .../competitiveness/orderbook-bids-only.json | 12 + .../competitiveness/orderbook-legacy-ids.json | 52 +++ .../competitiveness/orderbook-shared-top.json | 33 ++ .../fixtures/competitiveness/orders-open.json | 76 +++ 13 files changed, 802 insertions(+), 7 deletions(-) create mode 100644 src/commands/competitiveness.rs create mode 100644 tests/fixtures/competitiveness/orderbook-alone-on-top.json create mode 100644 tests/fixtures/competitiveness/orderbook-bids-only.json create mode 100644 tests/fixtures/competitiveness/orderbook-legacy-ids.json create mode 100644 tests/fixtures/competitiveness/orderbook-shared-top.json create mode 100644 tests/fixtures/competitiveness/orders-open.json diff --git a/AGENTS.md b/AGENTS.md index 7e22a07..ded28db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,11 @@ Run `make verify` before considering a change done — it's exactly what CI runs never cross the market, never exceed cover, never go under `minContracts`, stay inside the reward band, and touch only the sides given a price, so one side can never be orphaned into a non-earning position. + - `competitiveness.rs` holds the pure report logic behind + `orders competitiveness` (distance to fill and to competing orders); + `orders.rs` only fetches open orders and books. Open orders always come + from the `all=true` list filtered client-side: the `?propertyId=` query + reports a partially filled order's original quantity. - `src/client.rs` — HTTP against the SDK surface (`/public/v1`) and the internal website API (`--internal`, `/prod`). - `src/config.rs` — non-secret config; the API key is keychain-only. diff --git a/README.md b/README.md index b22dd04..389e188 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ $ lofty account coverage # what's reserved backing your bids vs f $ lofty account breakeven --margin 5 # fee-inclusive sell price (basis hides the buy fee) $ lofty account rebates # maker rebates earned, derived from your fills $ lofty orders list|get|create|cancel # mutations confirm, or --force +$ lofty orders competitiveness # your open orders: distance to a fill and to the competition $ lofty quote recenter --property-id --bid

# move a quote safely (dry run by default) $ lofty quote provision --property-id --bid

--ask # post a fresh two-sided quote $ lofty quote pull --property-id --keep-above

# stand down, keeping recovery asks @@ -140,6 +141,36 @@ establishes that side exists, while only in-band orders accrue score — so a position can be two-sided yet still score nothing, and the output distinguishes the two rather than collapsing them into one boolean. +### Example — how close are my orders to filling? + +`orders competitiveness` (alias `comp`) measures each of your open orders' +distance to a fill and to the competition, per property and side. It reads +your open orders and each property's order book; it never places or cancels +anything. + +| Side | `to fill` | `top vs next other` | +| --- | --- | --- | +| buy | your highest bid up to the lowest ask **not yours** (% of that ask): how far a seller must come down | the book's highest bid vs the highest bid **not yours** (% of the latter) | +| sell | the highest bid **not yours** up to your lowest ask (% of that bid): how far a buyer must come up | the lowest ask **not yours** vs the book's lowest ask (% of the former) | + +Your own orders are never the counterparty: your ask cannot fill your bid. The +book is aggregated by price with no order ids, so your share of a level is your +open quantity at that price and the remainder is someone else's. `lead` is how +far ahead of the best competing order you are (negative when behind). +Partially filled orders are included at their remaining quantity. Numbers below +are illustrative. + +```console +$ lofty orders competitiveness +COUNTERPARTY | LEAD | MINE | PROPERTY | QTY | SIDE | TO FILL | TOP VS NEXT OTHER +$53.10 | +$0.00 | $49.81 | 01SAMPLEPROP00000000000001 | 6 | buy | $3.29 (6.20%) | $0.00 (0.00%) +$49.81 | +$0.35 | $52.75 | 01SAMPLEPROP00000000000001 | 4 | sell | $2.94 (5.90%) | $0.35 (0.66%) +``` + +`--property-id` limits it to one property; `--json` emits +`orders-competitiveness/v1` with a `book` block and a `buy`/`sell` block per +property (`null` for a side you have no orders on). + ### Example — what can I actually spend? Lofty funds open orders from your live balances: buys need the USDC and sells diff --git a/src/commands/competitiveness.rs b/src/commands/competitiveness.rs new file mode 100644 index 0000000..3b1046d --- /dev/null +++ b/src/commands/competitiveness.rs @@ -0,0 +1,437 @@ +//! `lofty orders competitiveness` — how far your resting orders are from +//! filling, and how far the competition is from you. Read-only. +//! +//! Pure report logic lives here; the command in `orders.rs` only fetches your +//! open orders and each property's order book. + +use std::collections::{BTreeMap, BTreeSet}; + +use pk_cli_core::output; +use serde_json::{json, Value}; + +/// Prices are compared in whole cents: the book and the order list both carry +/// USD floats, and an exact float match would miss `49.81` vs `49.809999`. +fn cents(p: f64) -> i64 { + (p * 100.0).round() as i64 +} + +/// Snap float dirt off a derived figure (`53.10 - 49.81` → `3.29`, not +/// `3.2900000000000027`) so it reads cleanly in `--json`. +fn clean(x: f64) -> f64 { + (x * 1e6).round() / 1e6 +} + +/// One side of a property's book, split into everything resting and the part +/// that is not yours. +struct Side { + /// Every price level with quantity, yours included. + all: BTreeSet, + /// Price levels where someone other than you still has quantity. + others: BTreeSet, +} + +/// Split a book side into all levels and other people's levels. +/// +/// The current book (`orderbook.{bids,asks}`) is aggregated by price with no +/// order ids, so your share of a level is your open quantity at that price, +/// subtracted from the level's total; whatever remains belongs to someone else. +/// The older envelope (`orderbook.orderBook.{buyOrders,sellOrders}`) lists +/// individual orders with an `id`, and those are matched to your order ids +/// directly. +fn side(book: &Value, new_key: &str, old_key: &str, mine: &[&Value]) -> Side { + let levels = book + .pointer(&format!("/orderbook/{new_key}")) + .or_else(|| book.pointer(&format!("/orderbook/orderBook/{old_key}"))) + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + let my_ids: BTreeSet<&str> = mine + .iter() + .filter_map(|o| o.get("orderId").and_then(Value::as_str)) + .collect(); + let mut my_qty: BTreeMap = BTreeMap::new(); + for o in mine { + let p = o.get("price").and_then(Value::as_f64).unwrap_or(0.0); + let q = o.get("quantity").and_then(Value::as_f64).unwrap_or(0.0); + *my_qty.entry(cents(p)).or_default() += q; + } + + let mut all = BTreeSet::new(); + let mut others: BTreeMap = BTreeMap::new(); + let mut anonymous: BTreeMap = BTreeMap::new(); + for l in &levels { + let (Some(p), q) = ( + l.get("price").and_then(Value::as_f64), + l.get("quantity").and_then(Value::as_f64).unwrap_or(0.0), + ) else { + continue; + }; + if q <= 0.0 { + continue; + } + let c = cents(p); + all.insert(c); + match l + .get("id") + .or_else(|| l.get("orderId")) + .and_then(Value::as_str) + { + Some(id) if my_ids.contains(id) => {} + Some(_) => *others.entry(c).or_default() += q, + None => *anonymous.entry(c).or_default() += q, + } + } + for (c, q) in anonymous { + let rest = q - my_qty.get(&c).copied().unwrap_or(0.0); + if rest > 1e-9 { + *others.entry(c).or_default() += rest; + } + } + Side { + all, + others: others + .into_iter() + .filter(|(_, q)| *q > 1e-9) + .map(|(c, _)| c) + .collect(), + } +} + +fn usd(c: Option) -> Option { + c.map(|c| c as f64 / 100.0) +} + +/// Gap from `from` to `to` in dollars, and as a percent of `base`. +fn gap(from: Option, to: Option, base: Option) -> (Option, Option) { + match (from, to) { + (Some(a), Some(b)) => { + let d = clean(b - a); + let pct = base.filter(|x| *x > 0.0).map(|x| clean(d / x * 100.0)); + (Some(d), pct) + } + _ => (None, None), + } +} + +/// Competitiveness of your open orders on ONE property. Pure (unit-tested). +/// +/// `mine` is your open orders on this property (any direction); `book` is its +/// `/properties/{id}/orderbook` payload. +/// +/// Buy side: +/// - `fillGap`: from your highest bid UP to the lowest ask that is not yours — +/// how far a seller must come down to fill you, as a percent of that ask. +/// Your own ask cannot fill your own bid, so it is not the counterparty. +/// - `competitionGap`: from the highest bid that is not yours UP to the highest +/// bid in the book, as a percent of the former. Zero when someone else holds +/// (or shares) the top; positive when you are alone above the competition. +/// - `leadUsd`: your highest bid minus the highest bid not yours (negative when +/// you are behind). +/// +/// The sell side mirrors it: `fillGap` from the highest bid not yours UP to +/// your lowest ask (as a percent of that bid), `competitionGap` from the +/// lowest ask in the book UP to the lowest ask not yours, and `leadUsd` is how +/// far your lowest ask sits BELOW the lowest ask not yours. +pub fn property_report(property_id: &str, mine: &[Value], book: &Value) -> Value { + let dir = |d: &str| -> Vec<&Value> { + mine.iter() + .filter(|o| o.get("direction").and_then(Value::as_str) == Some(d)) + .collect() + }; + let (my_bids, my_asks) = (dir("buy"), dir("sell")); + let bids = side(book, "bids", "buyOrders", &my_bids); + let asks = side(book, "asks", "sellOrders", &my_asks); + + let book_best_bid = usd(bids.all.last().copied()); + let other_best_bid = usd(bids.others.last().copied()); + let book_best_ask = usd(asks.all.first().copied()); + let other_best_ask = usd(asks.others.first().copied()); + + let summary = |orders: &[&Value], best: fn(f64, f64) -> f64| -> Option<(f64, usize, f64)> { + let prices: Vec = orders + .iter() + .filter_map(|o| o.get("price").and_then(Value::as_f64)) + .collect(); + let first = *prices.first()?; + let qty: f64 = orders + .iter() + .filter_map(|o| o.get("quantity").and_then(Value::as_f64)) + .sum(); + Some((prices.iter().copied().fold(first, best), orders.len(), qty)) + }; + + let buy = summary(&my_bids, f64::max).map(|(my_best, n, qty)| { + let (fill, fill_pct) = gap(Some(my_best), other_best_ask, other_best_ask); + let (comp, comp_pct) = gap(other_best_bid, book_best_bid, other_best_bid); + json!({ + "orders": n, + "quantity": qty, + "myBestBid": my_best, + "lowestOtherAsk": other_best_ask, + "fillGapUsd": fill, + "fillGapPct": fill_pct, + "bookBestBid": book_best_bid, + "highestOtherBid": other_best_bid, + "competitionGapUsd": comp, + "competitionGapPct": comp_pct, + "leadUsd": other_best_bid.map(|o| clean(my_best - o)), + "atTop": book_best_bid.is_none_or(|b| cents(my_best) >= cents(b)), + }) + }); + let sell = summary(&my_asks, f64::min).map(|(my_best, n, qty)| { + let (fill, fill_pct) = gap(other_best_bid, Some(my_best), other_best_bid); + let (comp, comp_pct) = gap(book_best_ask, other_best_ask, book_best_ask); + json!({ + "orders": n, + "quantity": qty, + "myBestAsk": my_best, + "highestOtherBid": other_best_bid, + "fillGapUsd": fill, + "fillGapPct": fill_pct, + "bookBestAsk": book_best_ask, + "lowestOtherAsk": other_best_ask, + "competitionGapUsd": comp, + "competitionGapPct": comp_pct, + "leadUsd": other_best_ask.map(|o| clean(o - my_best)), + "atTop": book_best_ask.is_none_or(|a| cents(my_best) <= cents(a)), + }) + }); + + json!({ + "propertyId": property_id, + "book": { + "bestBid": book_best_bid, + "bestAsk": book_best_ask, + "highestOtherBid": other_best_bid, + "lowestOtherAsk": other_best_ask, + }, + "buy": buy, + "sell": sell, + }) +} + +/// Group open orders by property, preserving first-seen order. +pub fn by_property(open: &[Value]) -> Vec<(String, Vec)> { + let mut out: Vec<(String, Vec)> = Vec::new(); + for o in open { + let pid = o + .get("propertyId") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + match out.iter_mut().find(|(p, _)| *p == pid) { + Some((_, v)) => v.push(o.clone()), + None => out.push((pid, vec![o.clone()])), + } + } + out +} + +fn money(v: &Value, k: &str) -> String { + v.get(k) + .and_then(Value::as_f64) + .map(|x| format!("${x:.2}")) + .unwrap_or_else(|| "-".into()) +} + +fn signed(v: &Value, k: &str) -> String { + v.get(k) + .and_then(Value::as_f64) + .map(|x| { + if x < 0.0 { + format!("-${:.2}", -x) + } else { + format!("+${x:.2}") + } + }) + .unwrap_or_else(|| "-".into()) +} + +fn gap_text(v: &Value, usd_key: &str, pct_key: &str) -> String { + match ( + v.get(usd_key).and_then(Value::as_f64), + v.get(pct_key).and_then(Value::as_f64), + ) { + (Some(d), Some(p)) => format!("${d:.2} ({p:.2}%)"), + (Some(d), None) => format!("${d:.2}"), + _ => "-".into(), + } +} + +/// Human view: one row per property and side. +pub fn render(v: &Value) { + let props = v + .get("properties") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default(); + if props.is_empty() { + println!("no open orders"); + return; + } + let mut rows = Vec::new(); + for p in &props { + for (side, mine_key, other_key) in [ + ("buy", "myBestBid", "lowestOtherAsk"), + ("sell", "myBestAsk", "highestOtherBid"), + ] { + let s = &p[side]; + if s.is_null() { + continue; + } + rows.push(json!({ + "property": p["propertyId"], + "side": side, + "mine": money(s, mine_key), + "qty": s["quantity"], + "counterparty": money(s, other_key), + "to fill": gap_text(s, "fillGapUsd", "fillGapPct"), + "top vs next other": gap_text(s, "competitionGapUsd", "competitionGapPct"), + "lead": signed(s, "leadUsd"), + })); + } + } + output::table(&rows); +} + +#[cfg(test)] +mod tests { + use super::*; + + const P: &str = "01SAMPLEPROP00000000000001"; + + fn load(rel: &str) -> Value { + let path = format!("{}/tests/fixtures/{rel}", env!("CARGO_MANIFEST_DIR")); + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap() + } + + /// Open orders on P from the shared orders fixture (active only, as the + /// command selects them). + fn mine() -> Vec { + load("competitiveness/orders-open.json")["orders"] + .as_array() + .unwrap() + .iter() + .filter(|o| o["status"] == "active" && o["propertyId"] == P) + .cloned() + .collect() + } + + fn report(book: &str) -> Value { + property_report(P, &mine(), &load(&format!("competitiveness/{book}"))) + } + + #[test] + fn buy_fill_gap_is_measured_to_the_lowest_ask_not_yours() { + // Your bid 49.81; your own ask 52.75 is the book's best but cannot fill + // you, so the counterparty is the next ask, 53.10. + let r = report("orderbook-shared-top.json"); + assert_eq!(r["buy"]["myBestBid"], 49.81); + assert_eq!(r["buy"]["lowestOtherAsk"], 53.1); + assert_eq!(r["buy"]["fillGapUsd"], 3.29); + assert_eq!(r["buy"]["fillGapPct"], clean(3.29 / 53.1 * 100.0)); + } + + #[test] + fn a_shared_top_level_has_no_competition_gap() { + // The 49.81 level holds 6: 4 are yours (the partially filled order's + // REMAINING quantity), 2 are someone else's. They share the top. + let r = report("orderbook-shared-top.json"); + assert_eq!(r["buy"]["bookBestBid"], 49.81); + assert_eq!(r["buy"]["highestOtherBid"], 49.81); + assert_eq!(r["buy"]["competitionGapUsd"], 0.0); + assert_eq!(r["buy"]["leadUsd"], 0.0); + assert_eq!(r["buy"]["atTop"], true); + } + + #[test] + fn sell_side_mirrors_the_buy_side() { + // Your ask 52.75 alone at the top; the next ask not yours is 53.10, and + // the highest bid not yours is 49.81. + let r = report("orderbook-shared-top.json"); + let s = &r["sell"]; + assert_eq!(s["myBestAsk"], 52.75); + assert_eq!(s["highestOtherBid"], 49.81); + assert_eq!(s["fillGapUsd"], 2.94); + assert_eq!(s["fillGapPct"], clean(2.94 / 49.81 * 100.0)); + assert_eq!(s["bookBestAsk"], 52.75); + assert_eq!(s["lowestOtherAsk"], 53.1); + assert_eq!(s["competitionGapUsd"], 0.35); + assert_eq!(s["competitionGapPct"], clean(0.35 / 52.75 * 100.0)); + assert_eq!(s["leadUsd"], 0.35); + assert_eq!(s["orders"], 1); + } + + #[test] + fn alone_on_top_shows_the_lead_and_behind_shows_a_negative_one() { + // Bids: you alone at 49.81, the next other bid 49.00 → you lead by 0.81. + // Asks: someone else at 52.00 undercuts your 52.75 → you trail by 0.75. + let r = report("orderbook-alone-on-top.json"); + assert_eq!(r["buy"]["highestOtherBid"], 49.0); + assert_eq!(r["buy"]["competitionGapUsd"], 0.81); + assert_eq!(r["buy"]["competitionGapPct"], clean(0.81 / 49.0 * 100.0)); + assert_eq!(r["buy"]["leadUsd"], 0.81); + assert_eq!(r["sell"]["bookBestAsk"], 52.0); + assert_eq!(r["sell"]["lowestOtherAsk"], 52.0); + assert_eq!(r["sell"]["competitionGapUsd"], 0.0); + assert_eq!(r["sell"]["leadUsd"], -0.75); + assert_eq!(r["sell"]["atTop"], false); + // Buy fill gap now runs to the undercutting ask, not your own. + assert_eq!(r["buy"]["fillGapUsd"], 2.19); + } + + #[test] + fn legacy_envelope_matches_your_orders_by_id() { + // The older per-order envelope carries ids: an order with your id is + // yours regardless of price coincidences. + let r = report("orderbook-legacy-ids.json"); + assert_eq!(r["buy"]["bookBestBid"], 49.81); + assert_eq!(r["buy"]["highestOtherBid"], 49.5); + assert_eq!(r["buy"]["competitionGapUsd"], 0.31); + assert_eq!(r["sell"]["lowestOtherAsk"], 53.1); + } + + #[test] + fn an_empty_opposite_side_leaves_the_fill_gap_unknown() { + let r = property_report( + P, + &mine(), + &load("competitiveness/orderbook-bids-only.json"), + ); + assert!(r["buy"]["fillGapUsd"].is_null()); + assert!(r["buy"]["fillGapPct"].is_null()); + assert!(r["book"]["bestAsk"].is_null()); + } + + #[test] + fn a_side_with_no_orders_of_yours_is_null() { + let bids_only: Vec = mine() + .into_iter() + .filter(|o| o["direction"] == "buy") + .collect(); + let r = property_report( + P, + &bids_only, + &load("competitiveness/orderbook-shared-top.json"), + ); + assert!(r["sell"].is_null()); + assert!(!r["buy"].is_null()); + // Without your ask in `mine`, the 52.75 ask counts as someone else's. + assert_eq!(r["buy"]["lowestOtherAsk"], 52.75); + } + + #[test] + fn groups_open_orders_by_property() { + let open: Vec = load("competitiveness/orders-open.json")["orders"] + .as_array() + .unwrap() + .iter() + .filter(|o| o["status"] == "active") + .cloned() + .collect(); + let g = by_property(&open); + assert_eq!(g.len(), 2); + assert_eq!(g[0].0, P); + assert_eq!(g[0].1.len(), 3); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2565834..db03916 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -5,6 +5,7 @@ pub mod account; pub mod amm; pub mod api; pub mod catalog; +pub mod competitiveness; pub mod orders; pub mod properties; pub mod quote; diff --git a/src/commands/orders.rs b/src/commands/orders.rs index 906b1cc..8d98e5f 100644 --- a/src/commands/orders.rs +++ b/src/commands/orders.rs @@ -8,7 +8,7 @@ use clap::Subcommand; use pk_cli_core::{output, CliError}; use serde_json::{json, Value}; -use super::{confirm, emit, table_view, Ctx}; +use super::{competitiveness, confirm, emit, table_view, Ctx}; #[derive(Subcommand, Debug)] pub enum Cmd { @@ -24,6 +24,22 @@ pub enum Cmd { }, /// Get one order by ID. Get { order_id: String }, + /// How competitive your open orders are: distance to a fill and to the + /// competition, per property and side. Read-only. + /// + /// Buy side: the dollar and percent gap from your highest bid up to the + /// lowest ask that is not yours (how far a seller must come down to fill + /// you), and the gap between the book's highest bid and the highest bid that + /// is not yours. Sell side mirrors it: the gap from the highest bid not yours + /// up to your lowest ask, and between the book's lowest ask and the lowest + /// ask not yours. Partially filled orders are included at their remaining + /// quantity. + #[command(visible_alias = "comp")] + Competitiveness { + /// Only this property (default: every property you have open orders on). + #[arg(long)] + property_id: Option, + }, /// Place a limit order (requires a trading-enabled key). Create { #[arg(long)] @@ -64,15 +80,17 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { status, } => { let client = ctx.client()?; - let mut q: Vec<(&str, String)> = Vec::new(); - match property_id { - Some(id) => q.push(("propertyId", id.clone())), - None => q.push(("all", "true".into())), - } + // Always the `all=true` list, filtered here: the `?propertyId=` query + // reports a partially filled order's ORIGINAL quantity, while + // `all=true` reports what remains (see `fetch_state` in quote.rs). + let mut q: Vec<(&str, String)> = vec![("all", "true".into())]; if let Some(s) = status { q.push(("status", s.clone())); } - let payload = client.get("/public/v1/orders", &q)?; + let mut payload = client.get("/public/v1/orders", &q)?; + if let Some(id) = property_id { + retain_property(&mut payload, id); + } emit(ctx, "orders-list", payload, |v| { let orders = v .get("orders") @@ -94,6 +112,33 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { }); Ok(()) } + Cmd::Competitiveness { property_id } => { + let client = ctx.client()?; + let mut open = client.get("/public/v1/orders", &[("all", "true".into())])?; + if let Some(id) = property_id { + retain_property(&mut open, id); + } + let open: Vec = open + .get("orders") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|o| o.get("status").and_then(Value::as_str) == Some("active")) + .collect(); + let mut properties = Vec::new(); + for (pid, mine) in competitiveness::by_property(&open) { + let book = client.get(&format!("/public/v1/properties/{pid}/orderbook"), &[])?; + properties.push(competitiveness::property_report(&pid, &mine, &book)); + } + emit( + ctx, + "orders-competitiveness", + json!({ "properties": properties }), + competitiveness::render, + ); + Ok(()) + } Cmd::Get { order_id } => { let client = ctx.client()?; let payload = client.get(&format!("/public/v1/orders/{order_id}"), &[])?; @@ -200,6 +245,14 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { } } +/// Keep only one property's orders in an `/orders` payload, leaving every other +/// top-level field as the API sent it. +fn retain_property(payload: &mut Value, property_id: &str) { + if let Some(orders) = payload.get_mut("orders").and_then(Value::as_array_mut) { + orders.retain(|o| o.get("propertyId").and_then(Value::as_str) == Some(property_id)); + } +} + /// Everything known about what a token is currently worth, gathered from BOTH /// price sources because they disagree — an orderbook read of $50 against a real /// bid of $71.09 is what priced a sell 20% under the market. diff --git a/tests/cli_surface.rs b/tests/cli_surface.rs index 2886541..1b75da0 100644 --- a/tests/cli_surface.rs +++ b/tests/cli_surface.rs @@ -246,3 +246,17 @@ fn help_lists_the_quote_primitives() { .stdout(predicate::str::contains("--min-ask")) .stdout(predicate::str::contains("--allow-out-of-band")); } + +#[test] +fn orders_competitiveness_help_renders() { + lofty() + .args(["orders", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("competitiveness")); + lofty() + .args(["orders", "comp", "--help"]) + .assert() + .success() + .stdout(predicate::str::contains("--property-id")); +} diff --git a/tests/fixture_shapes.rs b/tests/fixture_shapes.rs index 5c3e3fc..157149d 100644 --- a/tests/fixture_shapes.rs +++ b/tests/fixture_shapes.rs @@ -284,3 +284,43 @@ fn fee_rates_use_two_different_unit_conventions() { ); } } + +#[test] +fn competitiveness_fixtures_mirror_the_live_order_and_book_shapes() { + // `orders competitiveness` attributes book quantity to you by matching your + // open orders' `price`/`quantity` (or `orderId` on the legacy per-order + // envelope). The synthetic fixtures must carry the same fields the captured + // ones do, or the tests prove nothing about live data. + let live_order = &fixture("account/orders-list.json")["orders"][0]; + let orders = fixture("competitiveness/orders-open.json"); + for o in orders["orders"].as_array().unwrap() { + for k in live_order.as_object().unwrap().keys() { + assert!(o.get(k).is_some(), "synthetic order missing `{k}`"); + } + } + let live_level = &fixture("public/property-orderbook.json")["orderbook"]["bids"][0]; + for name in [ + "orderbook-shared-top.json", + "orderbook-alone-on-top.json", + "orderbook-bids-only.json", + ] { + let book = fixture(&format!("competitiveness/{name}")); + for side in ["bids", "asks"] { + for l in book["orderbook"][side].as_array().unwrap() { + for k in live_level.as_object().unwrap().keys() { + assert!(l.get(k).is_some(), "{name} level missing `{k}`"); + } + } + } + } + let legacy = &fixture("orderbook.json")["data"]["orderBook"]["buyOrders"][0]; + let book = fixture("competitiveness/orderbook-legacy-ids.json"); + for l in book["orderbook"]["orderBook"]["buyOrders"] + .as_array() + .unwrap() + { + for k in legacy.as_object().unwrap().keys() { + assert!(l.get(k).is_some(), "legacy level missing `{k}`"); + } + } +} diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index ad66310..1598e3f 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -13,6 +13,14 @@ inspect. Tests load these files; they are never embedded as string literals. order-coverage paths (asks cover against held tokens, and only `active` orders consume cover at all). Shapes follow live responses, including `paymentCurrency: "any"` on sells. +- `competitiveness/` is **synthetic** (added 2026-10-02) for + `orders competitiveness`: one set of open orders (a partially filled bid + shown at its remaining quantity, a second bid, an ask, closed orders, and an + order on another property) against order books where your bid shares the + top level, sits alone on top, or is undercut, plus a bids-only book and the + legacy per-order envelope with ids. Field sets mirror the captured + `account/orders-list.json`, `public/property-orderbook.json`, and + `orderbook.json`, enforced in `fixture_shapes.rs`. - The old-fixtures at the top level (`marketplace.json`, `orderbook.json`, `property-info.json`) are from the internal `/prod` API, kept for the `api --internal` passthrough shapes. diff --git a/tests/fixtures/competitiveness/orderbook-alone-on-top.json b/tests/fixtures/competitiveness/orderbook-alone-on-top.json new file mode 100644 index 0000000..bcf5755 --- /dev/null +++ b/tests/fixtures/competitiveness/orderbook-alone-on-top.json @@ -0,0 +1,33 @@ +{ + "propertyId": "01SAMPLEPROP00000000000001", + "orderbook": { + "bids": [ + { + "price": 49.81, + "quantity": 4 + }, + { + "price": 49.5, + "quantity": 2 + }, + { + "price": 49, + "quantity": 10 + } + ], + "asks": [ + { + "price": 52, + "quantity": 3 + }, + { + "price": 52.75, + "quantity": 4 + }, + { + "price": 53.1, + "quantity": 5 + } + ] + } +} diff --git a/tests/fixtures/competitiveness/orderbook-bids-only.json b/tests/fixtures/competitiveness/orderbook-bids-only.json new file mode 100644 index 0000000..3a9876b --- /dev/null +++ b/tests/fixtures/competitiveness/orderbook-bids-only.json @@ -0,0 +1,12 @@ +{ + "propertyId": "01SAMPLEPROP00000000000001", + "orderbook": { + "bids": [ + { + "price": 49.81, + "quantity": 6 + } + ], + "asks": [] + } +} diff --git a/tests/fixtures/competitiveness/orderbook-legacy-ids.json b/tests/fixtures/competitiveness/orderbook-legacy-ids.json new file mode 100644 index 0000000..adc0220 --- /dev/null +++ b/tests/fixtures/competitiveness/orderbook-legacy-ids.json @@ -0,0 +1,52 @@ +{ + "propertyId": "01SAMPLEPROP00000000000001", + "orderbook": { + "propertyId": "01SAMPLEPROP00000000000001", + "orderBook": { + "buyOrders": [ + { + "quantity": 4, + "price": 49.81, + "propertyId": "01SAMPLEPROP00000000000001", + "id": "01SAMPLEORDER0000000000011", + "expireAt": 1787592000000, + "createdAt": 1785000000000 + }, + { + "quantity": 2, + "price": 49.5, + "propertyId": "01SAMPLEPROP00000000000001", + "id": "01SAMPLEOTHERORDER00000001", + "expireAt": 1787592000000, + "createdAt": 1785000000000 + }, + { + "quantity": 2, + "price": 49.5, + "propertyId": "01SAMPLEPROP00000000000001", + "id": "01SAMPLEORDER0000000000012", + "expireAt": 1787592000000, + "createdAt": 1785000000000 + } + ], + "sellOrders": [ + { + "quantity": 4, + "price": 52.75, + "propertyId": "01SAMPLEPROP00000000000001", + "id": "01SAMPLEORDER0000000000013", + "expireAt": 1787592000000, + "createdAt": 1785000000000 + }, + { + "quantity": 5, + "price": 53.1, + "propertyId": "01SAMPLEPROP00000000000001", + "id": "01SAMPLEOTHERORDER00000002", + "expireAt": 1787592000000, + "createdAt": 1785000000000 + } + ] + } + } +} diff --git a/tests/fixtures/competitiveness/orderbook-shared-top.json b/tests/fixtures/competitiveness/orderbook-shared-top.json new file mode 100644 index 0000000..15dd58d --- /dev/null +++ b/tests/fixtures/competitiveness/orderbook-shared-top.json @@ -0,0 +1,33 @@ +{ + "propertyId": "01SAMPLEPROP00000000000001", + "orderbook": { + "bids": [ + { + "price": 49.81, + "quantity": 6 + }, + { + "price": 49.5, + "quantity": 2 + }, + { + "price": 49, + "quantity": 10 + } + ], + "asks": [ + { + "price": 52.75, + "quantity": 4 + }, + { + "price": 53.1, + "quantity": 5 + }, + { + "price": 54, + "quantity": 3 + } + ] + } +} diff --git a/tests/fixtures/competitiveness/orders-open.json b/tests/fixtures/competitiveness/orders-open.json new file mode 100644 index 0000000..bd70134 --- /dev/null +++ b/tests/fixtures/competitiveness/orders-open.json @@ -0,0 +1,76 @@ +{ + "orders": [ + { + "orderId": "01SAMPLEORDER0000000000011", + "propertyId": "01SAMPLEPROP00000000000001", + "direction": "buy", + "price": 49.81, + "quantity": 4, + "status": "active", + "state": "open", + "paymentCurrency": "usdc", + "createdAt": 1785000000011, + "expireAt": 1787592000011 + }, + { + "orderId": "01SAMPLEORDER0000000000012", + "propertyId": "01SAMPLEPROP00000000000001", + "direction": "buy", + "price": 49.5, + "quantity": 2, + "status": "active", + "state": "open", + "paymentCurrency": "usdc", + "createdAt": 1785000000012, + "expireAt": 1787592000012 + }, + { + "orderId": "01SAMPLEORDER0000000000013", + "propertyId": "01SAMPLEPROP00000000000001", + "direction": "sell", + "price": 52.75, + "quantity": 4, + "status": "active", + "state": "open", + "paymentCurrency": "any", + "createdAt": 1785000000013, + "expireAt": 1787592000013 + }, + { + "orderId": "01SAMPLEORDER0000000000014", + "propertyId": "01SAMPLEPROP00000000000001", + "direction": "buy", + "price": 48, + "quantity": 5, + "status": "cancelled", + "state": "cancelled", + "paymentCurrency": "usdc", + "createdAt": 1785000000014, + "expireAt": 1787592000014 + }, + { + "orderId": "01SAMPLEORDER0000000000015", + "propertyId": "01SAMPLEPROP00000000000001", + "direction": "sell", + "price": 51, + "quantity": 0, + "status": "executed", + "state": "filled", + "paymentCurrency": "any", + "createdAt": 1785000000015, + "expireAt": 1787592000015 + }, + { + "orderId": "01SAMPLEORDER0000000000016", + "propertyId": "01SAMPLEPROP00000000000002", + "direction": "buy", + "price": 30, + "quantity": 1, + "status": "active", + "state": "open", + "paymentCurrency": "usdc", + "createdAt": 1785000000016, + "expireAt": 1787592000016 + } + ] +} From 9c77cc48e052c2da039b1bbca51d9eb16ea241a1 Mon Sep 17 00:00:00 2001 From: piekstra Date: Fri, 2 Oct 2026 10:33:05 -0400 Subject: [PATCH 2/3] refactor: one owner for book parsing and for open orders Review follow-up. - New `commands/book.rs` owns order-book parsing for both envelopes and the "book without my own orders" rule (id match on the legacy per-order envelope, quantity subtraction by cent on the aggregated one). The quote never-cross rail (`quote::market_from`), `orders competitiveness`, `orders create`'s market check, and `rewards` all read through it, so the rail and the report can no longer disagree about the market. - `orders::open_orders` is the single owner of "open orders come from the all=true list, filtered client-side"; competitiveness, quote, rewards eligibility, and account coverage call it. - Unit test pinning `retain_property` (`orders list --property-id`). Behaviour note: book levels with no positive quantity are no longer counted as resting liquidity anywhere (previously some readers included a zero-quantity level's price). --- AGENTS.md | 10 +- src/commands/account.rs | 7 +- src/commands/book.rs | 219 ++++++++++++++++++++++++++++++++ src/commands/competitiveness.rs | 111 +++------------- src/commands/mod.rs | 1 + src/commands/orders.rs | 86 ++++++++----- src/commands/quote.rs | 73 ++--------- src/commands/rewards.rs | 57 ++------- 8 files changed, 326 insertions(+), 238 deletions(-) create mode 100644 src/commands/book.rs diff --git a/AGENTS.md b/AGENTS.md index ded28db..9748f0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,9 +36,13 @@ Run `make verify` before considering a change done — it's exactly what CI runs side can never be orphaned into a non-earning position. - `competitiveness.rs` holds the pure report logic behind `orders competitiveness` (distance to fill and to competing orders); - `orders.rs` only fetches open orders and books. Open orders always come - from the `all=true` list filtered client-side: the `?propertyId=` query - reports a partially filled order's original quantity. + `orders.rs` only fetches open orders and books. + - `book.rs` owns order-book parsing (both envelopes) and the "book without + my own orders" rule, shared by `quote`'s never-cross rail, `orders + competitiveness`, and `rewards`. + - Open orders come only from `orders::open_orders` (the `all=true` list, + filtered client-side): the `?propertyId=` query reports a partially filled + order's original quantity. - `src/client.rs` — HTTP against the SDK surface (`/public/v1`) and the internal website API (`--internal`, `/prod`). - `src/config.rs` — non-secret config; the API key is keychain-only. diff --git a/src/commands/account.rs b/src/commands/account.rs index 110fe1b..d1f2979 100644 --- a/src/commands/account.rs +++ b/src/commands/account.rs @@ -174,12 +174,7 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { .get("usdc") .and_then(Value::as_f64) .unwrap_or(0.0); - let orders = client - .get("/public/v1/orders", &[("all", "true".into())])? - .get("orders") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); + let orders = super::orders::open_orders(&client, None)?; let positions = client .get("/public/v1/account/positions", &[])? .get("positions") diff --git a/src/commands/book.rs b/src/commands/book.rs new file mode 100644 index 0000000..65a025a --- /dev/null +++ b/src/commands/book.rs @@ -0,0 +1,219 @@ +//! Order-book parsing shared by every command that reads a property's book. +//! +//! Owns two rules that must not drift between commands: +//! - which envelope a side lives in: the current `orderbook.{bids,asks}` +//! (aggregated by price, no order ids) or the older +//! `orderbook.orderBook.{buyOrders,sellOrders}` (one entry per order, with +//! an `id`); +//! - what "the book without my own orders" means. `quote`'s never-cross rail +//! and `orders competitiveness` both read it, so they must agree. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::Value; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BookSide { + Bids, + Asks, +} + +impl BookSide { + fn keys(self) -> (&'static str, &'static str) { + match self { + Self::Bids => ("bids", "buyOrders"), + Self::Asks => ("asks", "sellOrders"), + } + } + + /// The order `direction` that rests on this side. + pub fn direction(self) -> &'static str { + match self { + Self::Bids => "buy", + Self::Asks => "sell", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Level { + pub price: f64, + pub qty: f64, + /// Present only on the legacy per-order envelope. + pub id: Option, +} + +/// Prices are compared in whole cents: the book and the order list both carry +/// USD floats, and an exact float match would miss `49.81` vs `49.809999`. +pub fn cents(p: f64) -> i64 { + (p * 100.0).round() as i64 +} + +/// Quantity below this is float residue from subtraction, not resting size. +const DUST: f64 = 1e-9; + +/// The resting levels on one side, from either envelope. Entries without a +/// numeric price, or with no positive quantity, are not resting liquidity and +/// are skipped. +pub fn levels(book: &Value, side: BookSide) -> Vec { + let (new_key, old_key) = side.keys(); + book.pointer(&format!("/orderbook/{new_key}")) + .or_else(|| book.pointer(&format!("/orderbook/orderBook/{old_key}"))) + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|l| { + let price = l.get("price").and_then(Value::as_f64)?; + let qty = l.get("quantity").and_then(Value::as_f64)?; + let id = l + .get("id") + .or_else(|| l.get("orderId")) + .and_then(Value::as_str) + .map(str::to_string); + (qty > DUST).then_some(Level { price, qty, id }) + }) + .collect() + }) + .unwrap_or_default() +} + +/// The levels with your own resting size removed. +/// +/// `mine` is your open orders on this property; only those resting on `side` +/// are considered. A level carrying an order id (legacy envelope) is yours +/// exactly when the id is one of your order ids. Levels without ids are +/// aggregated by price, and your open quantity at that price is subtracted: +/// whatever remains belongs to someone else. +pub fn without_mine(levels: &[Level], mine: &[Value], side: BookSide) -> Vec { + let mine: Vec<&Value> = mine + .iter() + .filter(|o| o.get("direction").and_then(Value::as_str) == Some(side.direction())) + .collect(); + let my_ids: BTreeSet<&str> = mine + .iter() + .filter_map(|o| o.get("orderId").and_then(Value::as_str)) + .collect(); + let mut my_qty: BTreeMap = BTreeMap::new(); + for o in &mine { + if let Some(p) = o.get("price").and_then(Value::as_f64) { + *my_qty.entry(cents(p)).or_default() += + o.get("quantity").and_then(Value::as_f64).unwrap_or(0.0); + } + } + + let mut out = Vec::new(); + let mut anonymous: BTreeMap = BTreeMap::new(); + for l in levels { + match &l.id { + Some(id) if my_ids.contains(id.as_str()) => {} + Some(_) => out.push(l.clone()), + None => *anonymous.entry(cents(l.price)).or_default() += l.qty, + } + } + for (c, q) in anonymous { + let rest = q - my_qty.get(&c).copied().unwrap_or(0.0); + if rest > DUST { + out.push(Level { + price: c as f64 / 100.0, + qty: rest, + id: None, + }); + } + } + out +} + +/// Best price on a side: the highest bid or the lowest ask. +pub fn best(levels: &[Level], side: BookSide) -> Option { + let prices = levels.iter().map(|l| l.price); + match side { + BookSide::Bids => prices.reduce(f64::max), + BookSide::Asks => prices.reduce(f64::min), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const P: &str = "01SAMPLEPROP00000000000001"; + + fn load(rel: &str) -> Value { + let path = format!("{}/tests/fixtures/{rel}", env!("CARGO_MANIFEST_DIR")); + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap() + } + + fn mine() -> Vec { + load("competitiveness/orders-open.json")["orders"] + .as_array() + .unwrap() + .iter() + .filter(|o| o["status"] == "active" && o["propertyId"] == P) + .cloned() + .collect() + } + + #[test] + fn reads_both_envelopes() { + let current = load("competitiveness/orderbook-shared-top.json"); + let legacy = load("competitiveness/orderbook-legacy-ids.json"); + assert_eq!( + best(&levels(¤t, BookSide::Bids), BookSide::Bids), + Some(49.81) + ); + assert_eq!( + best(&levels(¤t, BookSide::Asks), BookSide::Asks), + Some(52.75) + ); + assert_eq!( + best(&levels(&legacy, BookSide::Bids), BookSide::Bids), + Some(49.81) + ); + assert!(levels(&legacy, BookSide::Bids) + .iter() + .all(|l| l.id.is_some())); + } + + #[test] + fn subtracts_your_quantity_from_aggregated_levels() { + // 49.81 holds 6, of which 4 are yours; 49.50 holds exactly your 2. + let book = load("competitiveness/orderbook-shared-top.json"); + let others = without_mine(&levels(&book, BookSide::Bids), &mine(), BookSide::Bids); + let at = |p: f64| { + others + .iter() + .find(|l| cents(l.price) == cents(p)) + .map(|l| l.qty) + }; + assert_eq!(at(49.81), Some(2.0)); + assert_eq!(at(49.5), None); + assert_eq!(at(49.0), Some(10.0)); + } + + #[test] + fn legacy_levels_are_matched_by_order_id() { + // At 49.50 the legacy book lists your order AND someone else's of the + // same size; only yours is removed. + let book = load("competitiveness/orderbook-legacy-ids.json"); + let others = without_mine(&levels(&book, BookSide::Bids), &mine(), BookSide::Bids); + assert_eq!(best(&others, BookSide::Bids), Some(49.5)); + assert_eq!(others.len(), 1); + } + + #[test] + fn only_orders_on_the_same_side_are_subtracted() { + // Your ask at 52.75 must not eat into a bid level, or vice versa. + let book = load("competitiveness/orderbook-shared-top.json"); + let asks = without_mine(&levels(&book, BookSide::Asks), &mine(), BookSide::Asks); + assert_eq!(best(&asks, BookSide::Asks), Some(53.1)); + let bids = without_mine(&levels(&book, BookSide::Bids), &[], BookSide::Bids); + assert_eq!(bids.len(), 3); + } + + #[test] + fn an_empty_side_has_no_best() { + let book = load("competitiveness/orderbook-bids-only.json"); + assert!(levels(&book, BookSide::Asks).is_empty()); + assert_eq!(best(&levels(&book, BookSide::Asks), BookSide::Asks), None); + } +} diff --git a/src/commands/competitiveness.rs b/src/commands/competitiveness.rs index 3b1046d..659b06b 100644 --- a/src/commands/competitiveness.rs +++ b/src/commands/competitiveness.rs @@ -4,16 +4,10 @@ //! Pure report logic lives here; the command in `orders.rs` only fetches your //! open orders and each property's order book. -use std::collections::{BTreeMap, BTreeSet}; - use pk_cli_core::output; use serde_json::{json, Value}; -/// Prices are compared in whole cents: the book and the order list both carry -/// USD floats, and an exact float match would miss `49.81` vs `49.809999`. -fn cents(p: f64) -> i64 { - (p * 100.0).round() as i64 -} +use super::book::{self, cents, BookSide}; /// Snap float dirt off a derived figure (`53.10 - 49.81` → `3.29`, not /// `3.2900000000000027`) so it reads cleanly in `--json`. @@ -21,86 +15,6 @@ fn clean(x: f64) -> f64 { (x * 1e6).round() / 1e6 } -/// One side of a property's book, split into everything resting and the part -/// that is not yours. -struct Side { - /// Every price level with quantity, yours included. - all: BTreeSet, - /// Price levels where someone other than you still has quantity. - others: BTreeSet, -} - -/// Split a book side into all levels and other people's levels. -/// -/// The current book (`orderbook.{bids,asks}`) is aggregated by price with no -/// order ids, so your share of a level is your open quantity at that price, -/// subtracted from the level's total; whatever remains belongs to someone else. -/// The older envelope (`orderbook.orderBook.{buyOrders,sellOrders}`) lists -/// individual orders with an `id`, and those are matched to your order ids -/// directly. -fn side(book: &Value, new_key: &str, old_key: &str, mine: &[&Value]) -> Side { - let levels = book - .pointer(&format!("/orderbook/{new_key}")) - .or_else(|| book.pointer(&format!("/orderbook/orderBook/{old_key}"))) - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let my_ids: BTreeSet<&str> = mine - .iter() - .filter_map(|o| o.get("orderId").and_then(Value::as_str)) - .collect(); - let mut my_qty: BTreeMap = BTreeMap::new(); - for o in mine { - let p = o.get("price").and_then(Value::as_f64).unwrap_or(0.0); - let q = o.get("quantity").and_then(Value::as_f64).unwrap_or(0.0); - *my_qty.entry(cents(p)).or_default() += q; - } - - let mut all = BTreeSet::new(); - let mut others: BTreeMap = BTreeMap::new(); - let mut anonymous: BTreeMap = BTreeMap::new(); - for l in &levels { - let (Some(p), q) = ( - l.get("price").and_then(Value::as_f64), - l.get("quantity").and_then(Value::as_f64).unwrap_or(0.0), - ) else { - continue; - }; - if q <= 0.0 { - continue; - } - let c = cents(p); - all.insert(c); - match l - .get("id") - .or_else(|| l.get("orderId")) - .and_then(Value::as_str) - { - Some(id) if my_ids.contains(id) => {} - Some(_) => *others.entry(c).or_default() += q, - None => *anonymous.entry(c).or_default() += q, - } - } - for (c, q) in anonymous { - let rest = q - my_qty.get(&c).copied().unwrap_or(0.0); - if rest > 1e-9 { - *others.entry(c).or_default() += rest; - } - } - Side { - all, - others: others - .into_iter() - .filter(|(_, q)| *q > 1e-9) - .map(|(c, _)| c) - .collect(), - } -} - -fn usd(c: Option) -> Option { - c.map(|c| c as f64 / 100.0) -} - /// Gap from `from` to `to` in dollars, and as a percent of `base`. fn gap(from: Option, to: Option, base: Option) -> (Option, Option) { match (from, to) { @@ -132,20 +46,27 @@ fn gap(from: Option, to: Option, base: Option) -> (Option, O /// your lowest ask (as a percent of that bid), `competitionGap` from the /// lowest ask in the book UP to the lowest ask not yours, and `leadUsd` is how /// far your lowest ask sits BELOW the lowest ask not yours. -pub fn property_report(property_id: &str, mine: &[Value], book: &Value) -> Value { +pub fn property_report(property_id: &str, mine: &[Value], book_payload: &Value) -> Value { let dir = |d: &str| -> Vec<&Value> { mine.iter() .filter(|o| o.get("direction").and_then(Value::as_str) == Some(d)) .collect() }; let (my_bids, my_asks) = (dir("buy"), dir("sell")); - let bids = side(book, "bids", "buyOrders", &my_bids); - let asks = side(book, "asks", "sellOrders", &my_asks); - - let book_best_bid = usd(bids.all.last().copied()); - let other_best_bid = usd(bids.others.last().copied()); - let book_best_ask = usd(asks.all.first().copied()); - let other_best_ask = usd(asks.others.first().copied()); + // Your own size is attributed by `book::without_mine`, the same rule the + // `quote` never-cross rail uses. + let bids = book::levels(book_payload, BookSide::Bids); + let asks = book::levels(book_payload, BookSide::Asks); + let book_best_bid = book::best(&bids, BookSide::Bids); + let book_best_ask = book::best(&asks, BookSide::Asks); + let other_best_bid = book::best( + &book::without_mine(&bids, mine, BookSide::Bids), + BookSide::Bids, + ); + let other_best_ask = book::best( + &book::without_mine(&asks, mine, BookSide::Asks), + BookSide::Asks, + ); let summary = |orders: &[&Value], best: fn(f64, f64) -> f64| -> Option<(f64, usize, f64)> { let prices: Vec = orders diff --git a/src/commands/mod.rs b/src/commands/mod.rs index db03916..324f42a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,6 +4,7 @@ pub mod account; pub mod amm; pub mod api; +pub mod book; pub mod catalog; pub mod competitiveness; pub mod orders; diff --git a/src/commands/orders.rs b/src/commands/orders.rs index 8d98e5f..b55a79f 100644 --- a/src/commands/orders.rs +++ b/src/commands/orders.rs @@ -8,6 +8,7 @@ use clap::Subcommand; use pk_cli_core::{output, CliError}; use serde_json::{json, Value}; +use super::book::{self, BookSide}; use super::{competitiveness, confirm, emit, table_view, Ctx}; #[derive(Subcommand, Debug)] @@ -81,8 +82,8 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { } => { let client = ctx.client()?; // Always the `all=true` list, filtered here: the `?propertyId=` query - // reports a partially filled order's ORIGINAL quantity, while - // `all=true` reports what remains (see `fetch_state` in quote.rs). + // reports a partially filled order's ORIGINAL quantity (see + // `open_orders`). let mut q: Vec<(&str, String)> = vec![("all", "true".into())]; if let Some(s) = status { q.push(("status", s.clone())); @@ -114,18 +115,7 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { } Cmd::Competitiveness { property_id } => { let client = ctx.client()?; - let mut open = client.get("/public/v1/orders", &[("all", "true".into())])?; - if let Some(id) = property_id { - retain_property(&mut open, id); - } - let open: Vec = open - .get("orders") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - .into_iter() - .filter(|o| o.get("status").and_then(Value::as_str) == Some("active")) - .collect(); + let open = open_orders(&client, property_id.as_deref())?; let mut properties = Vec::new(); for (pid, mine) in competitiveness::by_property(&open) { let book = client.get(&format!("/public/v1/properties/{pid}/orderbook"), &[])?; @@ -245,6 +235,32 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { } } +/// Your open orders, optionally on one property. +/// +/// The single owner of the rule: always the `all=true` list, filtered here to +/// `status == active` (and to the property). The cheaper `?propertyId=` query +/// reports a partially filled order's ORIGINAL quantity, while `all=true` (and +/// the single-order GET) report what remains — raw-verified 2026-07-29, same +/// orderId: 3 vs 2 vs 2. Sizing, covering, or attributing book quantity off the +/// stale figure works from a position that no longer exists. +pub fn open_orders( + client: &crate::client::LoftyClient, + property_id: Option<&str>, +) -> Result, CliError> { + let mut payload = client.get("/public/v1/orders", &[("all", "true".into())])?; + if let Some(id) = property_id { + retain_property(&mut payload, id); + } + Ok(payload + .get("orders") + .and_then(Value::as_array) + .cloned() + .unwrap_or_default() + .into_iter() + .filter(|o| o.get("status").and_then(Value::as_str) == Some("active")) + .collect()) +} + /// Keep only one property's orders in an `/orders` payload, leaving every other /// top-level field as the API sent it. fn retain_property(payload: &mut Value, property_id: &str) { @@ -342,21 +358,8 @@ fn min_contracts_note_from(programs: &Value, property_id: &str, quantity: u32) - const MARKET_TOLERANCE_PCT: f64 = 5.0; fn market_view(book: Option<&Value>, trades: Option<&Value>) -> MarketView { - let side = |v: Option<&Value>, new_key: &str, old_key: &str, want_max: bool| -> Option { - let levels = v? - .pointer(&format!("/orderbook/{new_key}")) - .or_else(|| v?.pointer(&format!("/orderbook/orderBook/{old_key}")))? - .as_array()?; - levels - .iter() - .filter_map(|l| l.get("price").and_then(Value::as_f64)) - .fold(None, |acc: Option, p| { - Some(match acc { - Some(a) if want_max => a.max(p), - Some(a) => a.min(p), - None => p, - }) - }) + let side = |v: Option<&Value>, side: BookSide| -> Option { + book::best(&book::levels(v?, side), side) }; let recent: Vec<(f64, u64)> = trades .and_then(|t| t.get("recentTrades")) @@ -392,8 +395,8 @@ fn market_view(book: Option<&Value>, trades: Option<&Value>) -> MarketView { last_trade_at, median_recent, recent_low_high, - book_bid: side(book, "bids", "buyOrders", true), - book_ask: side(book, "asks", "sellOrders", false), + book_bid: side(book, BookSide::Bids), + book_ask: side(book, BookSide::Asks), feed_bid: trades .and_then(|t| t.get("bestBid")) .and_then(Value::as_f64), @@ -539,6 +542,27 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn retain_property_keeps_one_property_and_every_other_field() { + let path = format!( + "{}/tests/fixtures/competitiveness/orders-open.json", + env!("CARGO_MANIFEST_DIR") + ); + let mut payload: Value = + serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + payload["meta"] = json!({"kept": true}); + let total = payload["orders"].as_array().unwrap().len(); + retain_property(&mut payload, "01SAMPLEPROP00000000000001"); + let kept = payload["orders"].as_array().unwrap(); + assert!(!kept.is_empty() && kept.len() < total); + assert!(kept + .iter() + .all(|o| o["propertyId"] == "01SAMPLEPROP00000000000001")); + // Every status survives: `orders list` filters by property, not status. + assert!(kept.iter().any(|o| o["status"] != "active")); + assert_eq!(payload["meta"]["kept"], true); + } + fn book(bids: Vec, asks: Vec) -> Value { json!({"orderbook": { "bids": bids.iter().map(|p| json!({"price": p, "quantity": 1})).collect::>(), diff --git a/src/commands/quote.rs b/src/commands/quote.rs index e193d7a..2c17f3b 100644 --- a/src/commands/quote.rs +++ b/src/commands/quote.rs @@ -280,24 +280,8 @@ fn fetch_state(client: &crate::client::LoftyClient, property_id: &str) -> Result .cloned() }) .ok_or_else(|| CliError::NotFound(format!("no active LP program for {property_id}")))?; - // Deliberately the `all=true` list filtered client-side, NOT the cheaper - // `?propertyId=` query: after a PARTIAL fill the property-scoped list keeps - // reporting the order's ORIGINAL quantity, while `all=true` and the - // single-order GET both report what actually remains (raw-verified 2026-07-29, - // same orderId: 3 vs 2 vs 2). Sizing or covering a quote off the stale number - // works from a position that no longer exists. - let mine: Vec = client - .get("/public/v1/orders", &[("all", "true".to_string())])? - .get("orders") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - .into_iter() - .filter(|o| { - o.get("status").and_then(Value::as_str) == Some("active") - && o.get("propertyId").and_then(Value::as_str) == Some(property_id) - }) - .collect(); + // Remaining (not original) quantities after a partial fill: see `open_orders`. + let mine = super::orders::open_orders(client, Some(property_id))?; let book = client.get( &format!("/public/v1/properties/{property_id}/orderbook"), &[], @@ -436,56 +420,27 @@ fn apply_or_show( /// the reward band against) and best bid/ask with our own size removed (what a /// crossing check must compare to). fn market_from(program: &Value, mine: &[Value], book: &Value) -> Result { + use super::book::{best, levels, without_mine, BookSide}; let num = |v: &Value, k: &str| v.get(k).and_then(Value::as_f64).unwrap_or(0.0); - let levels = |new_key: &str, old_key: &str| -> Vec<(f64, f64)> { - book.pointer(&format!("/orderbook/{new_key}")) - .or_else(|| book.pointer(&format!("/orderbook/orderBook/{old_key}"))) - .and_then(Value::as_array) - .map(|a| { - a.iter() - .filter_map(|l| Some((l.get("price")?.as_f64()?, l.get("quantity")?.as_f64()?))) - .collect() - }) - .unwrap_or_default() - }; - let full_bid = levels("bids", "buyOrders") - .iter() - .map(|l| l.0) - .fold(f64::NEG_INFINITY, f64::max); - let full_ask = levels("asks", "sellOrders") - .iter() - .map(|l| l.0) - .fold(f64::INFINITY, f64::min); - if !full_bid.is_finite() || !full_ask.is_finite() { + let bids = levels(book, BookSide::Bids); + let asks = levels(book, BookSide::Asks); + let (Some(full_bid), Some(full_ask)) = + (best(&bids, BookSide::Bids), best(&asks, BookSide::Asks)) + else { return Err(CliError::Other( "order book is one-sided or empty — no mid to measure the band against".into(), )); - } - let mut bid_levels = levels("bids", "buyOrders"); - let mut ask_levels = levels("asks", "sellOrders"); - let subtract = |lv: &mut Vec<(f64, f64)>, dir: &str| { - for o in mine - .iter() - .filter(|o| o.get("direction").and_then(Value::as_str) == Some(dir)) - { - let (p, q) = (num(o, "price"), num(o, "quantity")); - if let Some(l) = lv.iter_mut().find(|l| (l.0 - p).abs() < 0.005) { - l.1 -= q; - } - } - lv.retain(|l| l.1 > 0.0); }; - subtract(&mut bid_levels, "buy"); - subtract(&mut ask_levels, "sell"); + // Our own size removed by the same rule `orders competitiveness` reports + // with, so the rail and the report can never disagree about the market. Ok(Market { mid: (full_bid + full_ask) / 2.0, spread: num(program, "allowedSpread"), min_contracts: num(program, "minContracts"), - mkt_best_bid: bid_levels - .iter() - .map(|l| l.0) - .fold(f64::NEG_INFINITY, f64::max), - mkt_best_ask: ask_levels.iter().map(|l| l.0).fold(f64::INFINITY, f64::min), + mkt_best_bid: best(&without_mine(&bids, mine, BookSide::Bids), BookSide::Bids) + .unwrap_or(f64::NEG_INFINITY), + mkt_best_ask: best(&without_mine(&asks, mine, BookSide::Asks), BookSide::Asks) + .unwrap_or(f64::INFINITY), }) } diff --git a/src/commands/rewards.rs b/src/commands/rewards.rs index 2abf507..a56f960 100644 --- a/src/commands/rewards.rs +++ b/src/commands/rewards.rs @@ -8,6 +8,7 @@ use clap::Subcommand; use pk_cli_core::{output, CliError}; use serde_json::Value; +use super::book::BookSide; use super::{emit, table_view, Ctx}; #[derive(Subcommand, Debug)] @@ -164,15 +165,7 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { .and_then(Value::as_array) .cloned() .unwrap_or_default(); - let orders: Vec = client - .get("/public/v1/orders", &[("all", "true".into())])? - .get("orders") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default() - .into_iter() - .filter(|o| o.get("status").and_then(Value::as_str) == Some("active")) - .collect(); + let orders = super::orders::open_orders(&client, None)?; let usdc = client .get("/public/v1/account/balance", &[])? .get("usdc") @@ -230,23 +223,9 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { } } -/// Best bid/ask from either order-book envelope (SDK 0.2.3+ `bids`/`asks`, or the -/// older `orderBook.buyOrders`/`sellOrders`). Levels aggregate by price. -fn best_of(book: &Value, new_key: &str, old_key: &str, want_max: bool) -> Option { - let levels = book - .pointer(&format!("/orderbook/{new_key}")) - .or_else(|| book.pointer(&format!("/orderbook/orderBook/{old_key}"))) - .and_then(Value::as_array)?; - levels - .iter() - .filter_map(|l| l.get("price").and_then(Value::as_f64)) - .fold(None, |acc: Option, p| { - Some(match acc { - Some(a) if want_max => a.max(p), - Some(a) => a.min(p), - None => p, - }) - }) +/// Best bid/ask from either order-book envelope (see `book::levels`). +fn best_of(book: &Value, side: BookSide) -> Option { + super::book::best(&super::book::levels(book, side), side) } /// Published per-order score (lofty.ai/lp-rewards): @@ -284,10 +263,7 @@ fn eligibility(program: &Value, mine: &[Value], book: &Value, usdc: f64, held: f .and_then(Value::as_str) .unwrap_or_default(); - let (best_bid, best_ask) = ( - best_of(book, "bids", "buyOrders", true), - best_of(book, "asks", "sellOrders", false), - ); + let (best_bid, best_ask) = (best_of(book, BookSide::Bids), best_of(book, BookSide::Asks)); let Some(mid) = best_bid.zip(best_ask).map(|(b, a)| (b + a) / 2.0) else { return serde_json::json!({ "propertyId": pid, "earning": false, @@ -369,21 +345,14 @@ fn eligibility(program: &Value, mine: &[Value], book: &Value, usdc: f64, held: f // Competition: every other in-band, sized level on the book. Our own resting // size is part of those levels, so subtract it to avoid counting it twice. - let side_score = |new_key: &str, old_key: &str| -> f64 { - book.pointer(&format!("/orderbook/{new_key}")) - .or_else(|| book.pointer(&format!("/orderbook/orderBook/{old_key}"))) - .and_then(Value::as_array) - .map(|levels| { - levels - .iter() - .filter_map(|l| Some((l.get("price")?.as_f64()?, l.get("quantity")?.as_f64()?))) - .filter(|(p, q)| *q >= min_contracts && (p - mid).abs() <= spread) - .map(|(p, q)| score_order(p - mid, spread, q)) - .sum() - }) - .unwrap_or(0.0) + let side_score = |side: BookSide| -> f64 { + super::book::levels(book, side) + .iter() + .filter(|l| l.qty >= min_contracts && (l.price - mid).abs() <= spread) + .map(|l| score_order(l.price - mid, spread, l.qty)) + .sum() }; - let book_score = side_score("bids", "buyOrders") + side_score("asks", "sellOrders"); + let book_score = side_score(BookSide::Bids) + side_score(BookSide::Asks); let competing = (book_score - our_score).max(0.0); let total = our_score + competing; let share = if earning && total > 0.0 { From 92660a442646c8a2ecd2384c5c61684d67b79d3b Mon Sep 17 00:00:00 2001 From: piekstra Date: Fri, 2 Oct 2026 10:36:32 -0400 Subject: [PATCH 3/3] fix(book): a level with no quantity field fails closed Review follow-up. `book::levels` skipped a level whose quantity field was absent, which would hide a reported ask from the orders-create crossing check. Such a level is now kept with an unknown (infinite) size, so its price still counts and subtracting your own size can never remove it; a level reporting quantity 0 is still treated as empty. Reward scoring skips unknown-size levels. Both cases are pinned in book.rs and in the orders create market-check tests. --- src/commands/book.rs | 40 +++++++++++++++++-- src/commands/orders.rs | 20 ++++++++++ src/commands/rewards.rs | 5 ++- tests/fixtures/README.md | 4 ++ .../orderbook-zero-and-missing-quantity.json | 24 +++++++++++ 5 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/book/orderbook-zero-and-missing-quantity.json diff --git a/src/commands/book.rs b/src/commands/book.rs index 65a025a..32a1c8c 100644 --- a/src/commands/book.rs +++ b/src/commands/book.rs @@ -52,9 +52,14 @@ pub fn cents(p: f64) -> i64 { /// Quantity below this is float residue from subtraction, not resting size. const DUST: f64 = 1e-9; -/// The resting levels on one side, from either envelope. Entries without a -/// numeric price, or with no positive quantity, are not resting liquidity and -/// are skipped. +/// The resting levels on one side, from either envelope. +/// +/// An entry without a numeric price is skipped, as is one that reports a +/// quantity of zero: that level is empty. An entry with a price but NO +/// quantity field is kept with an unknown (infinite) size. That fails closed: +/// the never-cross checks still see the price, and subtracting your own size +/// can never make it disappear. Readers that need a real size (reward scoring) +/// must skip non-finite quantities. pub fn levels(book: &Value, side: BookSide) -> Vec { let (new_key, old_key) = side.keys(); book.pointer(&format!("/orderbook/{new_key}")) @@ -64,7 +69,10 @@ pub fn levels(book: &Value, side: BookSide) -> Vec { a.iter() .filter_map(|l| { let price = l.get("price").and_then(Value::as_f64)?; - let qty = l.get("quantity").and_then(Value::as_f64)?; + let qty = match l.get("quantity") { + None | Some(Value::Null) => f64::INFINITY, + Some(q) => q.as_f64()?, + }; let id = l .get("id") .or_else(|| l.get("orderId")) @@ -210,6 +218,30 @@ mod tests { assert_eq!(bids.len(), 3); } + #[test] + fn zero_quantity_is_empty_but_missing_quantity_fails_closed() { + // Asks: 51 reports quantity 0 (empty), 52 reports no quantity at all, + // 53 x2. An unknown-size level must stay visible, even after your own + // same-price size is subtracted, or a crossing check fails open. + let book = load("book/orderbook-zero-and-missing-quantity.json"); + let asks = levels(&book, BookSide::Asks); + assert_eq!(asks.len(), 2); + assert_eq!(best(&asks, BookSide::Asks), Some(52.0)); + let my_ask = load("competitiveness/orders-open.json")["orders"] + .as_array() + .unwrap() + .iter() + .find(|o| o["direction"] == "sell" && o["status"] == "active") + .cloned() + .map(|mut o| { + o["price"] = serde_json::json!(52.0); + o + }) + .unwrap(); + let others = without_mine(&asks, &[my_ask], BookSide::Asks); + assert_eq!(best(&others, BookSide::Asks), Some(52.0)); + } + #[test] fn an_empty_side_has_no_best() { let book = load("competitiveness/orderbook-bids-only.json"); diff --git a/src/commands/orders.rs b/src/commands/orders.rs index b55a79f..ed82683 100644 --- a/src/commands/orders.rs +++ b/src/commands/orders.rs @@ -573,6 +573,26 @@ mod tests { "recentTrades": prints.iter().map(|(p,t)| json!({"price": p, "createdAt": t})).collect::>()}) } + #[test] + fn an_empty_ask_level_is_ignored_and_one_without_quantity_still_counts() { + // Safety-critical: the buy-side crossing objection reads the book's best + // ask. A zero-quantity level is empty and must not set it; a level with + // no quantity field must still set it (fail closed), or a crossing buy + // would pass unchallenged. + let path = format!( + "{}/tests/fixtures/book/orderbook-zero-and-missing-quantity.json", + env!("CARGO_MANIFEST_DIR") + ); + let b: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + let t = feed(50.0, 60.0, vec![(51.5, 1)]); + let m = market_view(Some(&b), Some(&t)); + assert_eq!(m.book_ask, Some(52.0)); + let why = m + .objection("buy", 52.5) + .expect("must object to a crossing buy"); + assert!(why.contains("52.00"), "{why}"); + } + #[test] fn refuses_the_sell_that_actually_lost_money() { // The real incident: the ORDERBOOK reported a $50 best bid while the trades diff --git a/src/commands/rewards.rs b/src/commands/rewards.rs index a56f960..466f598 100644 --- a/src/commands/rewards.rs +++ b/src/commands/rewards.rs @@ -348,7 +348,10 @@ fn eligibility(program: &Value, mine: &[Value], book: &Value, usdc: f64, held: f let side_score = |side: BookSide| -> f64 { super::book::levels(book, side) .iter() - .filter(|l| l.qty >= min_contracts && (l.price - mid).abs() <= spread) + .filter(|l| { + // An unknown-size level cannot be scored; skip it. + l.qty.is_finite() && l.qty >= min_contracts && (l.price - mid).abs() <= spread + }) .map(|l| score_order(l.price - mid, spread, l.qty)) .sum() }; diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 1598e3f..4dfaed3 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -21,6 +21,10 @@ inspect. Tests load these files; they are never embedded as string literals. legacy per-order envelope with ids. Field sets mirror the captured `account/orders-list.json`, `public/property-orderbook.json`, and `orderbook.json`, enforced in `fixture_shapes.rs`. +- `book/orderbook-zero-and-missing-quantity.json` is **synthetic** (added + 2026-10-02): an ask level reporting quantity 0 and one with no quantity + field, pinning how `book::levels` treats each (empty vs. unknown size, + counted fail-closed). - The old-fixtures at the top level (`marketplace.json`, `orderbook.json`, `property-info.json`) are from the internal `/prod` API, kept for the `api --internal` passthrough shapes. diff --git a/tests/fixtures/book/orderbook-zero-and-missing-quantity.json b/tests/fixtures/book/orderbook-zero-and-missing-quantity.json new file mode 100644 index 0000000..a7297e6 --- /dev/null +++ b/tests/fixtures/book/orderbook-zero-and-missing-quantity.json @@ -0,0 +1,24 @@ +{ + "propertyId": "01SAMPLEPROP00000000000001", + "orderbook": { + "bids": [ + { + "price": 50, + "quantity": 1 + } + ], + "asks": [ + { + "price": 51, + "quantity": 0 + }, + { + "price": 52 + }, + { + "price": 53, + "quantity": 2 + } + ] + } +}