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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ID> --bid <P> # move a quote safely (dry run by default)
$ lofty quote provision --property-id <ID> --bid <P> --ask <Q> # post a fresh two-sided quote
$ lofty quote pull --property-id <ID> --keep-above <P> # stand down, keeping recovery asks
Expand Down Expand Up @@ -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
Expand Down
7 changes: 1 addition & 6 deletions src/commands/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
251 changes: 251 additions & 0 deletions src/commands/book.rs
Original file line number Diff line number Diff line change
@@ -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<String>,
}

/// 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<Level> {
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<Level> {
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<i64, f64> = 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<i64, f64> = 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<f64> {
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<Value> {
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(&current, BookSide::Bids), BookSide::Bids),
Some(49.81)
);
assert_eq!(
best(&levels(&current, 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);
}
}
Loading
Loading