diff --git a/AGENTS.md b/AGENTS.md index 7e22a07..9748f0b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,15 @@ 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. + - `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/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/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..32a1c8c --- /dev/null +++ b/src/commands/book.rs @@ -0,0 +1,251 @@ +//! 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. +/// +/// 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}")) + .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 = 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")) + .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 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"); + 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 new file mode 100644 index 0000000..659b06b --- /dev/null +++ b/src/commands/competitiveness.rs @@ -0,0 +1,358 @@ +//! `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 pk_cli_core::output; +use serde_json::{json, Value}; + +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`. +fn clean(x: f64) -> f64 { + (x * 1e6).round() / 1e6 +} + +/// 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_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")); + // 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 + .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..324f42a 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -4,7 +4,9 @@ pub mod account; pub mod amm; pub mod api; +pub mod book; 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..ed82683 100644 --- a/src/commands/orders.rs +++ b/src/commands/orders.rs @@ -8,7 +8,8 @@ use clap::Subcommand; use pk_cli_core::{output, CliError}; use serde_json::{json, Value}; -use super::{confirm, emit, table_view, Ctx}; +use super::book::{self, BookSide}; +use super::{competitiveness, confirm, emit, table_view, Ctx}; #[derive(Subcommand, Debug)] pub enum Cmd { @@ -24,6 +25,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 +81,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 (see + // `open_orders`). + 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 +113,22 @@ pub fn run(ctx: &Ctx, cmd: &Cmd) -> Result<(), CliError> { }); Ok(()) } + Cmd::Competitiveness { property_id } => { + let client = ctx.client()?; + 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"), &[])?; + 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 +235,40 @@ 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) { + 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. @@ -289,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")) @@ -339,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), @@ -486,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::>(), @@ -496,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/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..466f598 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,17 @@ 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() + let side_score = |side: BookSide| -> f64 { + super::book::levels(book, side) + .iter() + .filter(|l| { + // An unknown-size level cannot be scored; skip it. + l.qty.is_finite() && l.qty >= min_contracts && (l.price - mid).abs() <= spread }) - .unwrap_or(0.0) + .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 { 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..4dfaed3 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -13,6 +13,18 @@ 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`. +- `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 + } + ] + } +} 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 + } + ] +}