From 5361dec7134c2e7c65f89899036476efcf81a86d Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 6 Oct 2026 18:17:11 +0300 Subject: [PATCH] feat: record the Ref pool's bid side over time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each pool read now writes a snapshot — totals, swap volumes, and what selling 10, 100, 1,000 and 5,000 of the asset would fetch from the pool's own `quote` view — and the order book whenever it changed (migration 0014). `/v1/chains/{chain}/ref/bids` turns that into the series #41 asks for: posted orders, money held otherwise, net swapped in, the quotes; the book now against N hours ago; and every level change, classified by `dcl::level_changes` — a shrink the pool's payout in the same minute accounts for is "taken by a swap" (consistent with one, not proof), the rest "withdrawn", allocated nearest the price first. The `ref-dcl` page shows it as four one-hue sparkline tiles and two tables, never one chart in several colours. Also records in CLAUDE.md why three deploys were cancelled: Gitea cancels runs by commit when a branch at that commit is deleted. Refs #41 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj --- ...2b2fd7ae4bee99521043a6ee5b005945b19e4.json | 23 ++ ...f429156968bd541b854cb643b092c2081b59b.json | 84 +++++ ...e6e17c0fbbb049ce06f2286b2b5ed963adec1.json | 26 ++ ...8ba36fc08d7be6c485eb6a836e45aedc3546f.json | 17 ++ ...4038e5d5ce750c5294e089aea74bcf926e9bb.json | 30 ++ CLAUDE.md | 11 + crates/blackbeard-api/src/refdcl.rs | 103 +++++++ crates/blackbeard-api/src/routes.rs | 220 +++++++++++++ crates/blackbeard-core/src/dcl.rs | 205 +++++++++++++ .../migrations/0014_ref_bid_side.sql | 43 +++ crates/blackbeard-data/src/store.rs | 187 ++++++++++++ crates/blackbeard-entities/src/intents.rs | 82 +++++ crates/blackbeard-entities/src/lib.rs | 7 +- web/src/api/generated/BidChange.ts | 28 ++ web/src/api/generated/BidLevel.ts | 18 ++ web/src/api/generated/BidSide.ts | 55 ++++ web/src/api/generated/BidSidePoint.ts | 30 ++ web/src/api/rest.ts | 14 + web/src/components/BidSidePanel.tsx | 288 ++++++++++++++++++ web/src/components/RefDclMarket.tsx | 10 + web/src/components/StatBar.tsx | 2 +- 21 files changed, 1479 insertions(+), 4 deletions(-) create mode 100644 .sqlx/query-2e942fa1bbe65e8d4779710df272b2fd7ae4bee99521043a6ee5b005945b19e4.json create mode 100644 .sqlx/query-4fe0a5f2a76e82f6044b4fb9761f429156968bd541b854cb643b092c2081b59b.json create mode 100644 .sqlx/query-6610f568efe388ddafd4b33cafae6e17c0fbbb049ce06f2286b2b5ed963adec1.json create mode 100644 .sqlx/query-cf30b6074ccff2995fbf42200608ba36fc08d7be6c485eb6a836e45aedc3546f.json create mode 100644 .sqlx/query-f28569d76e122b1dfbd3c7ec04c4038e5d5ce750c5294e089aea74bcf926e9bb.json create mode 100644 crates/blackbeard-data/migrations/0014_ref_bid_side.sql create mode 100644 web/src/api/generated/BidChange.ts create mode 100644 web/src/api/generated/BidLevel.ts create mode 100644 web/src/api/generated/BidSide.ts create mode 100644 web/src/api/generated/BidSidePoint.ts create mode 100644 web/src/components/BidSidePanel.tsx diff --git a/.sqlx/query-2e942fa1bbe65e8d4779710df272b2fd7ae4bee99521043a6ee5b005945b19e4.json b/.sqlx/query-2e942fa1bbe65e8d4779710df272b2fd7ae4bee99521043a6ee5b005945b19e4.json new file mode 100644 index 0000000..e67465e --- /dev/null +++ b/.sqlx/query-2e942fa1bbe65e8d4779710df272b2fd7ae4bee99521043a6ee5b005945b19e4.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "select min(at) as at from ref_pool_snapshot where chain = $1 and pool_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2e942fa1bbe65e8d4779710df272b2fd7ae4bee99521043a6ee5b005945b19e4" +} diff --git a/.sqlx/query-4fe0a5f2a76e82f6044b4fb9761f429156968bd541b854cb643b092c2081b59b.json b/.sqlx/query-4fe0a5f2a76e82f6044b4fb9761f429156968bd541b854cb643b092c2081b59b.json new file mode 100644 index 0000000..fff9bb9 --- /dev/null +++ b/.sqlx/query-4fe0a5f2a76e82f6044b4fb9761f429156968bd541b854cb643b092c2081b59b.json @@ -0,0 +1,84 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select at, current_point,\n total_x::text as \"total_x!\", total_y::text as \"total_y!\",\n total_order_x::text as \"total_order_x!\", total_order_y::text as \"total_order_y!\",\n volume_x_in::text as \"volume_x_in!\", volume_x_out::text as \"volume_x_out!\",\n volume_y_in::text as \"volume_y_in!\", volume_y_out::text as \"volume_y_out!\",\n quotes\n from ref_pool_snapshot\n where chain = $1 and pool_id = $2 and at >= $3\n order by at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "at", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "current_point", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "total_x!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "total_y!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "total_order_x!", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "total_order_y!", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "volume_x_in!", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "volume_x_out!", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "volume_y_in!", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "volume_y_out!", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "quotes", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + null, + null, + null, + null, + null, + null, + null, + null, + false + ] + }, + "hash": "4fe0a5f2a76e82f6044b4fb9761f429156968bd541b854cb643b092c2081b59b" +} diff --git a/.sqlx/query-6610f568efe388ddafd4b33cafae6e17c0fbbb049ce06f2286b2b5ed963adec1.json b/.sqlx/query-6610f568efe388ddafd4b33cafae6e17c0fbbb049ce06f2286b2b5ed963adec1.json new file mode 100644 index 0000000..3f08634 --- /dev/null +++ b/.sqlx/query-6610f568efe388ddafd4b33cafae6e17c0fbbb049ce06f2286b2b5ed963adec1.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "\n insert into ref_pool_snapshot\n (chain, pool_id, at, current_point, total_x, total_y, total_order_x,\n total_order_y, volume_x_in, volume_x_out, volume_y_in, volume_y_out, quotes)\n values ($1, $2, $3, $4, $5::text::numeric, $6::text::numeric, $7::text::numeric,\n $8::text::numeric, $9::text::numeric, $10::text::numeric,\n $11::text::numeric, $12::text::numeric, $13)\n on conflict (chain, pool_id, at) do nothing\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz", + "Int4", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "6610f568efe388ddafd4b33cafae6e17c0fbbb049ce06f2286b2b5ed963adec1" +} diff --git a/.sqlx/query-cf30b6074ccff2995fbf42200608ba36fc08d7be6c485eb6a836e45aedc3546f.json b/.sqlx/query-cf30b6074ccff2995fbf42200608ba36fc08d7be6c485eb6a836e45aedc3546f.json new file mode 100644 index 0000000..9c68213 --- /dev/null +++ b/.sqlx/query-cf30b6074ccff2995fbf42200608ba36fc08d7be6c485eb6a836e45aedc3546f.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n insert into ref_pool_book (chain, pool_id, at, levels)\n select $1, $2, $3, $4\n where not exists (\n select 1 from (\n select levels from ref_pool_book\n where chain = $1 and pool_id = $2\n order by at desc limit 1\n ) last\n where last.levels = $4\n )\n on conflict (chain, pool_id, at) do nothing\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "cf30b6074ccff2995fbf42200608ba36fc08d7be6c485eb6a836e45aedc3546f" +} diff --git a/.sqlx/query-f28569d76e122b1dfbd3c7ec04c4038e5d5ce750c5294e089aea74bcf926e9bb.json b/.sqlx/query-f28569d76e122b1dfbd3c7ec04c4038e5d5ce750c5294e089aea74bcf926e9bb.json new file mode 100644 index 0000000..31ecc5d --- /dev/null +++ b/.sqlx/query-f28569d76e122b1dfbd3c7ec04c4038e5d5ce750c5294e089aea74bcf926e9bb.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select at as \"at!\", levels as \"levels!\" from (\n (select at, levels from ref_pool_book\n where chain = $1 and pool_id = $2 and at < $3\n order by at desc limit 1)\n union all\n (select at, levels from ref_pool_book\n where chain = $1 and pool_id = $2 and at >= $3)\n ) b\n order by at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "levels!", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "f28569d76e122b1dfbd3c7ec04c4038e5d5ce750c5294e089aea74bcf926e9bb" +} diff --git a/CLAUDE.md b/CLAUDE.md index c6962fe..bea3554 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1193,6 +1193,17 @@ byte-identical to the local build. Unequal means it has not landed yet. Grepping the fetched bundle for a string the change introduced is the same check by another road, and works when the local build is not to hand. +**Delete a merged branch only after `main`'s deploy has finished.** Three +`main` deploys on 2026-10-06 died in their first seconds with `context +canceled`, cancelled from outside the job with no error and no record of who +cancelled them, while `deploy.yaml`'s concurrency says `cancel-in-progress: +false`. Each ran on a commit that was also a branch's head, and that branch was +deleted from the remote — or pushed again at the same commit — while the deploy +was building. Gitea evidently cancels runs by commit as well as by ref. The +symptom is easy to miss: the watcher reports the run finished, the jobs read +`skipped`, and `/v1/healthz` still names the old commit. A dispatch of +`deploy.yaml` on `main` (mode `deploy`) re-runs it. + **A frontend-only push skips the Rust build and the API deploy.** The `what changed` step in `deploy.yaml` diffs against `github.event.before` and sets one output; the gate, the musl build, the ts-rs drift check and the whole diff --git a/crates/blackbeard-api/src/refdcl.rs b/crates/blackbeard-api/src/refdcl.rs index 942620d..07c4896 100644 --- a/crates/blackbeard-api/src/refdcl.rs +++ b/crates/blackbeard-api/src/refdcl.rs @@ -57,6 +57,10 @@ const DISCOVER_EVERY: u32 = 10; /// `list_pools` page size. const LIST_PAGE: u64 = 200; +/// Sizes, in whole units of the asset, whose sale is quoted on every pool read +/// (blackbeard/observer#41): what a seller of that much would be paid now. +pub const QUOTE_SIZES: [u64; 4] = [10, 100, 1_000, 5_000]; + /// The widest point range DCL allows, for `get_liquidity_range`. const POINT_BOUND: i32 = 800_000; @@ -285,6 +289,86 @@ async fn discover( Ok(out) } +/// Write one pool read to the bid-side history (blackbeard/observer#41): the +/// snapshot, its quotes, and the order book when it changed. A failure here is +/// logged and the pass goes on — the live view must not depend on the record. +#[allow(clippy::too_many_arguments)] +async fn record_history( + store: &Store, + near: &NearClient, + chain: &blackbeard_entities::ChainId, + contract: &str, + pool_id: &str, + at: chrono::DateTime, + state: &Value, + order_book: &Value, + asset_contract: &str, + counter_contract: &str, + asset_decimals: u8, +) { + let mut quotes = serde_json::Map::new(); + for size in QUOTE_SIZES { + let amount = u128::from(size) * 10u128.pow(u32::from(asset_decimals)); + match near + .view_anywhere( + contract, + "quote", + json!({"pool_ids": [pool_id], "input_token": asset_contract, + "output_token": counter_contract, "input_amount": amount.to_string(), + "tag": null}), + ) + .await + { + Ok(q) => { + if let Some(out) = q["amount"].as_str() { + quotes.insert(amount.to_string(), Value::String(out.to_owned())); + } + } + // Left out rather than written as zero: an unanswered quote is not + // a pool that would pay nothing. + Err(e) => tracing::debug!(%pool_id, size, error = %e, "quote unreadable"), + } + } + let text = |k: &str| state[k].as_str().unwrap_or("0").to_owned(); + let snapshot = blackbeard_data::store::PoolSnapshot { + chain: chain.clone(), + pool_id: pool_id.to_owned(), + at, + current_point: state["current_point"].as_i64().unwrap_or(0) as i32, + total_x: text("total_x"), + total_y: text("total_y"), + total_order_x: text("total_order_x"), + total_order_y: text("total_order_y"), + volume_x_in: text("volume_x_in"), + volume_x_out: text("volume_x_out"), + volume_y_in: text("volume_y_in"), + volume_y_out: text("volume_y_out"), + quotes: Value::Object(quotes), + }; + if let Err(e) = store.record_pool_snapshot(&snapshot).await { + tracing::warn!(%pool_id, error = %e, "pool snapshot not recorded"); + } + // Sorted by point so equal books compare equal in the store. + let mut levels: Vec = order_book + .as_object() + .into_iter() + .flat_map(|o| o.values()) + .filter_map(|o| { + Some( + json!({"point": o["point"].as_i64()?, "amount_x": o["amount_x"].as_str()?, + "amount_y": o["amount_y"].as_str()?}), + ) + }) + .collect(); + levels.sort_by_key(|l| l["point"].as_i64()); + if let Err(e) = store + .record_pool_book(chain, pool_id, at, &Value::Array(levels)) + .await + { + tracing::warn!(%pool_id, error = %e, "pool book not recorded"); + } +} + fn num(v: &Value, k: &str) -> u128 { v.get(k) .and_then(Value::as_str) @@ -303,6 +387,10 @@ async fn read_pools( let id = chain.id(); let asset_meta = store.near_token(&config.asset).await?; let totals = store.ref_swap_totals(&id, &config.asset).await?; + // One instant for the whole pass: a snapshot and the book written beside + // it share it, which is what lets a book change be paired with the swap + // volume at the same moment. + let read_at = chrono::Utc::now(); let mut pools = Vec::new(); for (contract, pool_id) in known { let state = near @@ -337,6 +425,21 @@ async fn read_pools( }; let (dx, dy) = if asset_is_y { (cd, ad) } else { (ad, cd) }; + record_history( + store, + near, + &id, + contract, + pool_id, + read_at, + &state, + &order_book, + asset_contract, + counter_contract, + ad, + ) + .await; + let current = state["current_point"].as_i64().unwrap_or(0) as i32; let liquidity_x = num(&state, "liquidity_x"); let ranges: Vec = ranges diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index edf4367..314366e 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -73,6 +73,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .route("/v1/chains/{chain}/ref/pools", get(ref_pools)) .route("/v1/chains/{chain}/ref/swaps", get(ref_swaps)) .route("/v1/chains/{chain}/ref/series", get(ref_series)) + .route("/v1/chains/{chain}/ref/bids", get(ref_bids)) .route("/v1/chains/{chain}/bridge", get(bridge)) .route("/v1/chains/{chain}/nodes", get(nodes)) .route("/v1/chains/{chain}/nodes/{peer}", get(node)) @@ -2484,6 +2485,225 @@ async fn intents_series( /// mixed with it: a pool swap is usually a solver restocking after an intents /// fill, so one line over both would count one demand twice. Every pool swap /// is a fill against the pool, so `taker_trades` equals `trades`. +#[derive(Debug, Deserialize)] +struct BidsQuery { + /// The pool, `token_x|token_y|fee`. + pool_id: Option, + /// Hours of history, default a day. + hours: Option, +} + +/// Points kept in the bid-side series; a longer span is thinned evenly. +const BID_SERIES_POINTS: usize = 300; + +/// A Ref DCL pool's bid side over time (blackbeard/observer#41): what is posted +/// in limit orders, what is held otherwise, the net of what buyers paid in and +/// sellers took out, what fixed sales would fetch, the order book now and then, +/// and how each level changed. +async fn ref_bids( + State(state): State, + Path(chain): Path, + Query(query): Query, +) -> Result, Failure> { + use blackbeard_core::dcl; + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let config = runtime + .spec + .intents + .clone() + .ok_or_else(|| no_intents(&chain))?; + let id = runtime.id(); + let pools = runtime + .ref_pools + .read() + .unwrap_or_else(|p| p.into_inner()) + .clone() + .ok_or_else(|| { + Failure( + StatusCode::SERVICE_UNAVAILABLE, + ApiError::new("not_read", "the pools have not been read yet"), + ) + })?; + let pool = match query.pool_id.as_deref() { + Some(pid) => pools.pools.iter().find(|p| p.pool_id == pid), + None => pools.pools.first(), + } + .cloned() + .ok_or_else(|| { + Failure( + StatusCode::NOT_FOUND, + ApiError::new("no_pool", "no such pool holds the asset"), + ) + })?; + let asset_contract = config.token_contract().unwrap_or_default().to_owned(); + let asset_is_y = pool.pool_id.split('|').nth(1) == Some(asset_contract.as_str()); + let (Some(ad), cd) = (pools.asset_decimals, pool.counter_decimals) else { + return Err(Failure( + StatusCode::SERVICE_UNAVAILABLE, + ApiError::new("no_decimals", "the asset's decimals are not known yet"), + )); + }; + let (dx, dy) = if asset_is_y { (cd, ad) } else { (ad, cd) }; + let price = |p: i32| { + let y_in_x = dcl::price_y_in_x(p, dx, dy); + if asset_is_y { y_in_x } else { 1.0 / y_in_x } + }; + let whole = |raw: u128, d: u8| raw as f64 / 10f64.powi(i32::from(d)); + let n = |s: &str| s.parse::().unwrap_or(0); + + let hours = query.hours.unwrap_or(24).clamp(1, 24 * 30); + let since = chrono::Utc::now() - chrono::Duration::hours(hours as i64); + let snapshots = state + .store + .pool_snapshots(&id, &pool.pool_id, since) + .await + .map_err(database_unavailable)?; + let books = state + .store + .pool_books(&id, &pool.pool_id, since) + .await + .map_err(database_unavailable)?; + let recorded_since = state + .store + .pool_first_snapshot(&id, &pool.pool_id) + .await + .map_err(database_unavailable)?; + + // The counter token is x when the asset is y: bids are x orders, and a + // seller of the asset is paid out x. + let sizes = crate::refdcl::QUOTE_SIZES; + let step = snapshots.len().div_ceil(BID_SERIES_POINTS).max(1); + let points: Vec = snapshots + .iter() + .enumerate() + .filter(|(i, _)| i % step == 0 || *i + 1 == snapshots.len()) + .map(|(_, s)| { + let (total, posted, vin, vout) = counter_side(s, asset_is_y); + let (total, posted) = (n(total), n(posted)); + blackbeard_entities::BidSidePoint { + at: s.at, + posted: whole(posted, cd), + held: whole(total.saturating_sub(posted), cd), + net_swapped: (n(vin) as f64 - n(vout) as f64) / 10f64.powi(i32::from(cd)), + quotes: sizes + .iter() + .map(|size| { + let sold = u128::from(*size) * 10u128.pow(u32::from(ad)); + s.quotes + .get(sold.to_string()) + .and_then(|v| v.as_str()) + .map(|out| whole(n(out), cd) / *size as f64) + }) + .collect(), + } + }) + .collect(); + + // A book's counter-side levels, as (point, amount). + let side = |levels: &serde_json::Value| -> Vec<(i32, u128)> { + let key = if asset_is_y { "amount_x" } else { "amount_y" }; + levels + .as_array() + .into_iter() + .flatten() + .filter_map(|l| { + Some(( + l["point"].as_i64()? as i32, + l[key].as_str()?.parse::().ok()?, + )) + }) + .filter(|(_, a)| *a > 0) + .collect() + }; + let level = |(point, amount): (i32, u128)| { + let rate = 1.0001f64.powi(point); + let absorbs = if asset_is_y { + amount as f64 * rate + } else { + amount as f64 / rate + }; + blackbeard_entities::BidLevel { + price: price(point), + counter: whole(amount, cd), + absorbs: absorbs / 10f64.powi(i32::from(ad)), + } + }; + let ladder = |levels: &serde_json::Value| { + let mut v: Vec = + side(levels).into_iter().map(level).collect(); + v.sort_by(|a, b| b.price.total_cmp(&a.price)); + v + }; + // Nearest the price first, the order a seller's swap reaches the bids in. + let nearest = |a: i32, b: i32| if asset_is_y { a.cmp(&b) } else { b.cmp(&a) }; + let paid_out_at = |at: chrono::DateTime| { + snapshots + .iter() + .find(|s| s.at == at) + .map(|s| n(counter_side(s, asset_is_y).3)) + }; + let mut changes = Vec::new(); + for pair in books.windows(2) { + let ((t0, b0), (t1, b1)) = (&pair[0], &pair[1]); + // Without both snapshots the payout is unknown, and a shrink cannot be + // told filled from cancelled; it is not reported as either. + let Some(paid) = paid_out_at(*t0) + .zip(paid_out_at(*t1)) + .map(|(a, b)| b.saturating_sub(a)) + else { + continue; + }; + for c in dcl::level_changes(&side(b0), &side(b1), paid, nearest) { + changes.push(blackbeard_entities::BidChange { + at: *t1, + price: price(c.point), + kind: c.kind.as_str().to_owned(), + before: whole(c.before, cd), + after: whole(c.after, cd), + }); + } + } + changes.reverse(); + + let then = books.first(); + Ok(Json(blackbeard_entities::BidSide { + pool_id: pool.pool_id.clone(), + asset_symbol: pools.asset_symbol.clone(), + counter_symbol: pool.counter_symbol.clone(), + hours, + recorded_since, + quote_sizes: sizes.to_vec(), + points, + book_now: books.last().map(|(_, l)| ladder(l)).unwrap_or_default(), + book_then: then.map(|(_, l)| ladder(l)).unwrap_or_default(), + book_then_at: then.map(|(at, _)| (*at).max(since)), + changes, + })) +} + +/// A snapshot's counter-token figures: `(total, in orders, swapped in, paid +/// out)`. The counter is x when the asset is y. +fn counter_side( + s: &blackbeard_data::store::PoolSnapshot, + asset_is_y: bool, +) -> (&str, &str, &str, &str) { + if asset_is_y { + ( + &s.total_x, + &s.total_order_x, + &s.volume_x_in, + &s.volume_x_out, + ) + } else { + ( + &s.total_y, + &s.total_order_y, + &s.volume_y_in, + &s.volume_y_out, + ) + } +} + async fn ref_series( State(state): State, Path(chain): Path, diff --git a/crates/blackbeard-core/src/dcl.rs b/crates/blackbeard-core/src/dcl.rs index a411106..b9f5557 100644 --- a/crates/blackbeard-core/src/dcl.rs +++ b/crates/blackbeard-core/src/dcl.rs @@ -177,6 +177,115 @@ pub fn order_rungs(orders: &[Order]) -> Vec { out } +/// What happened to one order level between two reads of the book +/// (blackbeard/observer#41). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LevelChangeKind { + /// A point with no order now has one. + Added, + /// An existing order grew. + Raised, + /// It shrank, and the pool paid out that much of the token in the same + /// interval: a swap took it, wholly or in part. + Filled, + /// It shrank by more than any swap in the interval paid out: its owner + /// took it back. + Cancelled, +} + +impl LevelChangeKind { + /// The wire name. + pub fn as_str(self) -> &'static str { + match self { + Self::Added => "added", + Self::Raised => "raised", + Self::Filled => "filled", + Self::Cancelled => "cancelled", + } + } +} + +/// One level's change in one token's orders. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LevelChange { + /// The point. + pub point: i32, + /// What happened. + pub kind: LevelChangeKind, + /// The order's amount before, base units. + pub before: u128, + /// And after. + pub after: u128, +} + +/// How the orders for one token changed between two books, given how much of +/// that token the pool paid out in between (`paid_out`, the growth of its +/// `volume_*_out`). +/// +/// A shrinking level is a fill only as far as swaps account for it. The payout +/// is spent on the shrinking levels in the order a swap reaches them — nearest +/// the current price first, so `nearest_first` says which way that is — and +/// whatever shrinkage the payout cannot cover is a cancellation. One level can +/// therefore yield both. `paid_out` includes what liquidity paid out too, so a +/// fill is "a swap could have taken this", never proof that one did: without +/// the pool's own order events that is as far as the views go. +pub fn level_changes( + before: &[(i32, u128)], + after: &[(i32, u128)], + paid_out: u128, + nearest_first: impl Fn(i32, i32) -> std::cmp::Ordering, +) -> Vec { + use std::collections::BTreeMap; + let b: BTreeMap = before.iter().copied().filter(|(_, a)| *a > 0).collect(); + let a: BTreeMap = after.iter().copied().filter(|(_, a)| *a > 0).collect(); + let mut points: Vec = b.keys().chain(a.keys()).copied().collect(); + points.sort_by(|x, y| nearest_first(*x, *y)); + points.dedup(); + + let mut budget = paid_out; + let mut out = Vec::new(); + for point in points { + let (was, now) = ( + b.get(&point).copied().unwrap_or(0), + a.get(&point).copied().unwrap_or(0), + ); + if now > was { + let kind = if was == 0 { + LevelChangeKind::Added + } else { + LevelChangeKind::Raised + }; + out.push(LevelChange { + point, + kind, + before: was, + after: now, + }); + } else if now < was { + let shrink = was - now; + let filled = shrink.min(budget); + budget -= filled; + if filled > 0 { + out.push(LevelChange { + point, + kind: LevelChangeKind::Filled, + before: was, + after: was - filled, + }); + } + if filled < shrink { + out.push(LevelChange { + point, + kind: LevelChangeKind::Cancelled, + before: was - filled, + after: now, + }); + } + } + } + out +} + /// One `dcl.ref` `swap` event. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DclSwap { @@ -342,6 +451,102 @@ pub fn pool_withdrawals(logs: &[String], pools: &BTreeSet) -> Vec std::cmp::Ordering { + a.cmp(&b) + } + + #[test] + fn a_shrink_the_swaps_paid_for_is_a_fill_and_the_rest_a_cancel() { + // Two bid levels; a seller's swap paid out 2,000 of x. The nearest + // level (108,200) loses 2,000 to it; the farther one drops by 5,000 + // with nothing left to account for it — cancelled. + let before = [(108_200, 6_000), (111_080, 7_500)]; + let after = [(108_200, 4_000), (111_080, 2_500)]; + let c = level_changes(&before, &after, 2_000, up); + assert_eq!( + c, + vec![ + LevelChange { + point: 108_200, + kind: LevelChangeKind::Filled, + before: 6_000, + after: 4_000 + }, + LevelChange { + point: 111_080, + kind: LevelChangeKind::Cancelled, + before: 7_500, + after: 2_500 + }, + ] + ); + } + + #[test] + fn a_payout_larger_than_one_level_spills_to_the_next() { + let before = [(108_200, 1_000), (111_080, 3_000)]; + let after = [(111_080, 2_500)]; + let c = level_changes(&before, &after, 1_500, up); + assert_eq!( + c[0], + LevelChange { + point: 108_200, + kind: LevelChangeKind::Filled, + before: 1_000, + after: 0 + } + ); + assert_eq!( + c[1], + LevelChange { + point: 111_080, + kind: LevelChangeKind::Filled, + before: 3_000, + after: 2_500 + } + ); + assert_eq!(c.len(), 2); + } + + #[test] + fn a_level_that_vanishes_with_no_swap_was_cancelled_and_a_new_one_added() { + let before = [(108_200, 6_000)]; + let after = [(134_200, 8_988)]; + let c = level_changes(&before, &after, 0, up); + assert_eq!( + c, + vec![ + LevelChange { + point: 108_200, + kind: LevelChangeKind::Cancelled, + before: 6_000, + after: 0 + }, + LevelChange { + point: 134_200, + kind: LevelChangeKind::Added, + before: 0, + after: 8_988 + }, + ] + ); + } + + #[test] + fn a_partial_fill_and_a_partial_cancel_on_one_level_are_both_reported() { + let c = level_changes(&[(108_200, 6_000)], &[(108_200, 1_000)], 2_000, up); + assert_eq!(c.len(), 2); + assert_eq!( + (c[0].kind, c[0].before, c[0].after), + (LevelChangeKind::Filled, 6_000, 4_000) + ); + assert_eq!( + (c[1].kind, c[1].before, c[1].after), + (LevelChangeKind::Cancelled, 4_000, 1_000) + ); + } + #[test] fn a_bid_order_absorbs_its_usdc_at_its_own_point() { // The USDC/QTC pool's bid book, 2026-10-06 ~17:00 EEST, and the QTC diff --git a/crates/blackbeard-data/migrations/0014_ref_bid_side.sql b/crates/blackbeard-data/migrations/0014_ref_bid_side.sql new file mode 100644 index 0000000..41047e1 --- /dev/null +++ b/crates/blackbeard-data/migrations/0014_ref_bid_side.sql @@ -0,0 +1,43 @@ +-- The bid side of Ref DCL pools holding the tracked asset, over time. +-- blackbeard/observer#41. +-- +-- The pool's state is read once a minute (refdcl::pools) and was only ever +-- held in memory, so the page could say what the bid side is but never how it +-- got there. A seller deciding when to sell needs the second: whether posted +-- orders are growing or being pulled, and whether buyers' own money is coming +-- in or going out. +-- +-- One snapshot row per pool per read: the totals, the cumulative swap +-- volumes, and what selling fixed amounts of the asset would fetch at that +-- moment (the pool's own `quote` view: a computation from pool state, not +-- anyone's offer). Amounts are base units, as the contract reports them. +create table ref_pool_snapshot ( + chain text not null references chain (id) on delete cascade, + pool_id text not null, + at timestamptz not null, + current_point integer not null, + total_x numeric not null, + total_y numeric not null, + total_order_x numeric not null, + total_order_y numeric not null, + volume_x_in numeric not null, + volume_x_out numeric not null, + volume_y_in numeric not null, + volume_y_out numeric not null, + -- {"": ""}; a size + -- the view could not answer is left out, never written as zero. + quotes jsonb not null default '{}'::jsonb, + primary key (chain, pool_id, at) +); + +-- The limit-order book, written only when it differs from the last one +-- written: the book as it stood at any moment is the latest row at or before +-- it. `levels` is `[{"point", "amount_x", "amount_y"}]`, base units as +-- strings, what remained open at each point. +create table ref_pool_book ( + chain text not null references chain (id) on delete cascade, + pool_id text not null, + at timestamptz not null, + levels jsonb not null, + primary key (chain, pool_id, at) +); diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index 34ac3f4..1893b5d 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -299,6 +299,38 @@ pub struct IntentTrade { pub legs: serde_json::Value, } +/// One read of a Ref DCL pool's state, base units as decimal strings +/// (blackbeard/observer#41). +#[derive(Debug, Clone)] +pub struct PoolSnapshot { + /// Which chain's token. + pub chain: ChainId, + /// `token_x|token_y|fee`. + pub pool_id: String, + /// When it was read. + pub at: DateTime, + /// The point the next swap starts at. + pub current_point: i32, + /// Token x held, fees included. + pub total_x: String, + /// Token y held, fees included. + pub total_y: String, + /// Token x in open limit orders. + pub total_order_x: String, + /// Token y in open limit orders. + pub total_order_y: String, + /// Cumulative x swapped in. + pub volume_x_in: String, + /// Cumulative x paid out by swaps. + pub volume_x_out: String, + /// Cumulative y swapped in. + pub volume_y_in: String, + /// Cumulative y paid out. + pub volume_y_out: String, + /// `{"": ""}`, base units. + pub quotes: serde_json::Value, +} + /// One swap in a Ref DCL pool, as stored. Amounts are decimal strings in base /// units, as `IntentTrade`'s are. #[derive(Debug, Clone)] @@ -3467,6 +3499,161 @@ impl Store { .collect()) } + /// Record one read of a pool's state (blackbeard/observer#41). + pub async fn record_pool_snapshot(&self, snap: &PoolSnapshot) -> Result<(), DataError> { + sqlx::query!( + r#" + insert into ref_pool_snapshot + (chain, pool_id, at, current_point, total_x, total_y, total_order_x, + total_order_y, volume_x_in, volume_x_out, volume_y_in, volume_y_out, quotes) + values ($1, $2, $3, $4, $5::text::numeric, $6::text::numeric, $7::text::numeric, + $8::text::numeric, $9::text::numeric, $10::text::numeric, + $11::text::numeric, $12::text::numeric, $13) + on conflict (chain, pool_id, at) do nothing + "#, + snap.chain.as_str(), + snap.pool_id, + snap.at, + snap.current_point, + snap.total_x, + snap.total_y, + snap.total_order_x, + snap.total_order_y, + snap.volume_x_in, + snap.volume_x_out, + snap.volume_y_in, + snap.volume_y_out, + snap.quotes, + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Record a pool's order book at `at` if it differs from the last one + /// recorded. `levels` must be sorted by point, so that equal books compare + /// equal. One statement, so two passes cannot both decide it changed. + pub async fn record_pool_book( + &self, + chain: &ChainId, + pool_id: &str, + at: DateTime, + levels: &serde_json::Value, + ) -> Result { + let done = sqlx::query!( + r#" + insert into ref_pool_book (chain, pool_id, at, levels) + select $1, $2, $3, $4 + where not exists ( + select 1 from ( + select levels from ref_pool_book + where chain = $1 and pool_id = $2 + order by at desc limit 1 + ) last + where last.levels = $4 + ) + on conflict (chain, pool_id, at) do nothing + "#, + chain.as_str(), + pool_id, + at, + levels, + ) + .execute(&self.pool) + .await?; + Ok(done.rows_affected() > 0) + } + + /// A pool's snapshots since `since`, oldest first. + pub async fn pool_snapshots( + &self, + chain: &ChainId, + pool_id: &str, + since: DateTime, + ) -> Result, DataError> { + let rows = sqlx::query!( + r#" + select at, current_point, + total_x::text as "total_x!", total_y::text as "total_y!", + total_order_x::text as "total_order_x!", total_order_y::text as "total_order_y!", + volume_x_in::text as "volume_x_in!", volume_x_out::text as "volume_x_out!", + volume_y_in::text as "volume_y_in!", volume_y_out::text as "volume_y_out!", + quotes + from ref_pool_snapshot + where chain = $1 and pool_id = $2 and at >= $3 + order by at + "#, + chain.as_str(), + pool_id, + since, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| PoolSnapshot { + chain: chain.clone(), + pool_id: pool_id.to_owned(), + at: r.at, + current_point: r.current_point, + total_x: r.total_x, + total_y: r.total_y, + total_order_x: r.total_order_x, + total_order_y: r.total_order_y, + volume_x_in: r.volume_x_in, + volume_x_out: r.volume_x_out, + volume_y_in: r.volume_y_in, + volume_y_out: r.volume_y_out, + quotes: r.quotes, + }) + .collect()) + } + + /// Every book recorded since `since`, oldest first, preceded by the one in + /// force at `since` itself when there is one — the book "then". + pub async fn pool_books( + &self, + chain: &ChainId, + pool_id: &str, + since: DateTime, + ) -> Result, serde_json::Value)>, DataError> { + let rows = sqlx::query!( + r#" + select at as "at!", levels as "levels!" from ( + (select at, levels from ref_pool_book + where chain = $1 and pool_id = $2 and at < $3 + order by at desc limit 1) + union all + (select at, levels from ref_pool_book + where chain = $1 and pool_id = $2 and at >= $3) + ) b + order by at + "#, + chain.as_str(), + pool_id, + since, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|r| (r.at, r.levels)).collect()) + } + + /// When this observer first recorded the pool — where its history starts. + pub async fn pool_first_snapshot( + &self, + chain: &ChainId, + pool_id: &str, + ) -> Result>, DataError> { + let row = sqlx::query!( + "select min(at) as at from ref_pool_snapshot where chain = $1 and pool_id = $2", + chain.as_str(), + pool_id, + ) + .fetch_one(&self.pool) + .await?; + Ok(row.at) + } + /// Summed amounts per pool and direction, for coverage against the pool's /// own counters: `(pool_id, token_in, token_out, amount_in, amount_out, swaps)`. pub async fn ref_swap_totals( diff --git a/crates/blackbeard-entities/src/intents.rs b/crates/blackbeard-entities/src/intents.rs index bb0d99f..9c2eeee 100644 --- a/crates/blackbeard-entities/src/intents.rs +++ b/crates/blackbeard-entities/src/intents.rs @@ -472,3 +472,85 @@ pub struct RefSwaps { /// Cursor for the next older page, `near_height:tx_hash`. pub next: Option, } + +/// One moment of a pool's bid side, in whole units of the counter token +/// (blackbeard/observer#41). +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "BidSidePoint.ts")] +pub struct BidSidePoint { + /// When the pool was read. + pub at: DateTime, + /// Counter token in open limit orders: posted bids, cancellable at will. + pub posted: f64, + /// Counter token held outside orders — liquidity, buyers' payments and + /// uncollected fees. Moves only by swaps or a provider withdrawing. + pub held: f64, + /// Net counter token swapped in since the pool opened: buyers' payments + /// less what sellers were paid. Falling means sellers outpace buyers. + pub net_swapped: f64, + /// Average price a seller of each [`BidSide::quote_sizes`] amount would + /// get, in the counter per asset, same order; `None` where unanswered. + pub quotes: Vec>, +} + +/// One limit-order bid level. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "BidLevel.ts")] +pub struct BidLevel { + /// The asset's price there, in the counter. + pub price: f64, + /// Counter token bid, whole units. + pub counter: f64, + /// The asset it absorbs, whole units, before the pool's fee. + pub absorbs: f64, +} + +/// A change to one bid level between two reads of the book. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "BidChange.ts")] +pub struct BidChange { + /// When the book showing it was read. + pub at: DateTime, + /// The level's price. + pub price: f64, + /// `added`, `raised`, `filled` or `cancelled`. A fill is a shrink that the + /// pool's payout in the interval accounts for — consistent with a swap, not + /// proof of one. + pub kind: String, + /// Counter token bid there before, whole units. + pub before: f64, + /// And after. + pub after: f64, +} + +/// A Ref DCL pool's bid side over time: what a seller of the asset can be +/// paid, and how that has been changing (blackbeard/observer#41). +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "BidSide.ts")] +pub struct BidSide { + /// `token_x|token_y|fee`. + pub pool_id: String, + /// The asset's symbol. + pub asset_symbol: Option, + /// The counter token's symbol. + pub counter_symbol: Option, + /// Hours the series covers. + #[ts(type = "number")] + pub hours: u64, + /// When this observer first read the pool: no history exists before it, + /// whatever `hours` asks for. + pub recorded_since: Option>, + /// Amounts of the asset whose sale is quoted, whole units. + #[ts(type = "Array")] + pub quote_sizes: Vec, + /// The series, oldest first, thinned to at most a few hundred points. + pub points: Vec, + /// The bid book now, best price first. + pub book_now: Vec, + /// The book as it stood `hours` ago, or at `recorded_since` if later. + pub book_then: Vec, + /// When `book_then` was in force. + pub book_then_at: Option>, + /// Every level change in the span, newest first. + pub changes: Vec, +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 6832970..389abb9 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -42,9 +42,10 @@ pub use chain::{ }; pub use error::{ApiError, EntityError}; pub use intents::{ - CounterPrice, IntentsInventory, IntentsListing, IntentsPrice, IntentsSeries, - IntentsSeriesPoint, IntentsSwap, IntentsSwaps, InventoryHolder, InventoryToken, MarketSummary, - Markets, NamedHolder, RefPool, RefPools, RefRung, RefSwap, RefSwaps, + BidChange, BidLevel, BidSide, BidSidePoint, CounterPrice, IntentsInventory, IntentsListing, + IntentsPrice, IntentsSeries, IntentsSeriesPoint, IntentsSwap, IntentsSwaps, InventoryHolder, + InventoryToken, MarketSummary, Markets, NamedHolder, RefPool, RefPools, RefRung, RefSwap, + RefSwaps, }; pub use miner::{ AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, NodeActivity, diff --git a/web/src/api/generated/BidChange.ts b/web/src/api/generated/BidChange.ts new file mode 100644 index 0000000..7b6c0f8 --- /dev/null +++ b/web/src/api/generated/BidChange.ts @@ -0,0 +1,28 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A change to one bid level between two reads of the book. + */ +export type BidChange = { +/** + * When the book showing it was read. + */ +at: string, +/** + * The level's price. + */ +price: number, +/** + * `added`, `raised`, `filled` or `cancelled`. A fill is a shrink that the + * pool's payout in the interval accounts for — consistent with a swap, not + * proof of one. + */ +kind: string, +/** + * Counter token bid there before, whole units. + */ +before: number, +/** + * And after. + */ +after: number, }; diff --git a/web/src/api/generated/BidLevel.ts b/web/src/api/generated/BidLevel.ts new file mode 100644 index 0000000..8a5c526 --- /dev/null +++ b/web/src/api/generated/BidLevel.ts @@ -0,0 +1,18 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One limit-order bid level. + */ +export type BidLevel = { +/** + * The asset's price there, in the counter. + */ +price: number, +/** + * Counter token bid, whole units. + */ +counter: number, +/** + * The asset it absorbs, whole units, before the pool's fee. + */ +absorbs: number, }; diff --git a/web/src/api/generated/BidSide.ts b/web/src/api/generated/BidSide.ts new file mode 100644 index 0000000..99e8fe7 --- /dev/null +++ b/web/src/api/generated/BidSide.ts @@ -0,0 +1,55 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BidChange } from "./BidChange"; +import type { BidLevel } from "./BidLevel"; +import type { BidSidePoint } from "./BidSidePoint"; + +/** + * A Ref DCL pool's bid side over time: what a seller of the asset can be + * paid, and how that has been changing (blackbeard/observer#41). + */ +export type BidSide = { +/** + * `token_x|token_y|fee`. + */ +pool_id: string, +/** + * The asset's symbol. + */ +asset_symbol: string | null, +/** + * The counter token's symbol. + */ +counter_symbol: string | null, +/** + * Hours the series covers. + */ +hours: number, +/** + * When this observer first read the pool: no history exists before it, + * whatever `hours` asks for. + */ +recorded_since: string | null, +/** + * Amounts of the asset whose sale is quoted, whole units. + */ +quote_sizes: Array, +/** + * The series, oldest first, thinned to at most a few hundred points. + */ +points: Array, +/** + * The bid book now, best price first. + */ +book_now: Array, +/** + * The book as it stood `hours` ago, or at `recorded_since` if later. + */ +book_then: Array, +/** + * When `book_then` was in force. + */ +book_then_at: string | null, +/** + * Every level change in the span, newest first. + */ +changes: Array, }; diff --git a/web/src/api/generated/BidSidePoint.ts b/web/src/api/generated/BidSidePoint.ts new file mode 100644 index 0000000..ccc1993 --- /dev/null +++ b/web/src/api/generated/BidSidePoint.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One moment of a pool's bid side, in whole units of the counter token + * (blackbeard/observer#41). + */ +export type BidSidePoint = { +/** + * When the pool was read. + */ +at: string, +/** + * Counter token in open limit orders: posted bids, cancellable at will. + */ +posted: number, +/** + * Counter token held outside orders — liquidity, buyers' payments and + * uncollected fees. Moves only by swaps or a provider withdrawing. + */ +held: number, +/** + * Net counter token swapped in since the pool opened: buyers' payments + * less what sellers were paid. Falling means sellers outpace buyers. + */ +net_swapped: number, +/** + * Average price a seller of each [`BidSide::quote_sizes`] amount would + * get, in the counter per asset, same order; `None` where unanswered. + */ +quotes: Array, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index 31a5c56..0caeebc 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -6,6 +6,7 @@ * Pushing it to every connected tab would be strictly worse. */ +import type { BidSide } from './generated/BidSide' import type { AccountDetail } from './generated/AccountDetail' import type { AccountEvent } from './generated/AccountEvent' import type { AccountRow } from './generated/AccountRow' @@ -328,6 +329,19 @@ export async function fetchIntentsSeries( ) } +/** A Ref DCL pool's bid side over the last `hours` (blackbeard/observer#41). */ +export async function fetchRefBids( + chain: string, + poolId: string, + hours: number, + signal?: AbortSignal, +): Promise { + return get( + `/chains/${chain}/ref/bids?pool_id=${encodeURIComponent(poolId)}&hours=${hours}`, + signal, + ) +} + /** The asset's price in its Ref DCL pools against one counter token. */ export async function fetchRefSeries( chain: string, diff --git a/web/src/components/BidSidePanel.tsx b/web/src/components/BidSidePanel.tsx new file mode 100644 index 0000000..4d29846 --- /dev/null +++ b/web/src/components/BidSidePanel.tsx @@ -0,0 +1,288 @@ +/** + * A Ref DCL pool's bid side over time: what a seller of the asset can be paid, + * and how that is changing (blackbeard/observer#41). + * + * The ladder above says what is bid now. This says where it came from and + * where it is going, which is the part a seller deciding *when* to sell needs: + * + * - **Posted** is limit-order money. It is most of the bid side and its owner + * can take it back at any moment, so it is shown apart from everything else. + * - **Held otherwise** moves only by swaps or a provider withdrawing. + * - **Net swapped in** is buyers' payments less what sellers were paid: falling + * means sellers are outpacing buyers. + * - **Selling N fetches** is the pool's own `quote` view at fixed sizes: a + * computation from pool state, never an offer — the solver's spread and the + * pool's fee come on top. + * + * Every chart is one series in one hue (CLAUDE.md "Charts"): four tiles, each + * its own sparkline, rather than one chart with four colours. The book then and + * now, and every level change, are tables. + */ + +import { useEffect, useState } from 'react' + +import type { BidSide } from '../api/generated/BidSide' +import { RequestFailed, fetchRefBids } from '../api/rest' +import { price as fmtPrice, utc, when } from '../lib/format' +import type { SparkPoint } from './Sparkline' +import { TrendStat } from './StatBar' + +/** Spans offered, in hours. */ +const SPANS: [number, string][] = [ + [6, '6 h'], + [24, '24 h'], + [24 * 7, '7 d'], +] + +const REFRESH_MS = 60_000 + +/** Whole units, compact. */ +function amount(v: number, places = 0): string { + return v.toLocaleString('en-US', { maximumFractionDigits: places }) +} + +/** How each kind of change reads. */ +const CHANGE: Record = { + added: 'posted', + raised: 'raised', + filled: 'taken by a swap', + cancelled: 'withdrawn', +} + +export function BidSidePanel({ + chain, + poolId, + refresh, +}: { + chain: string + poolId: string + /** Changes when the pools were re-read, so the panel follows them. */ + refresh: string +}) { + const [hours, setHours] = useState(24) + const [size, setSize] = useState(0) + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + const controller = new AbortController() + const load = () => + fetchRefBids(chain, poolId, hours, controller.signal) + .then((d) => { + setData(d) + setError(null) + }) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + load() + const id = window.setInterval(load, REFRESH_MS) + return () => { + window.clearInterval(id) + controller.abort() + } + }, [chain, poolId, hours, refresh]) + + const asset = data?.asset_symbol ?? 'the asset' + const counter = data?.counter_symbol ?? 'the counter token' + const points = data?.points ?? [] + const last = points[points.length - 1] + const spark = ( + pick: (p: (typeof points)[number]) => number | null, + fmt: (v: number) => string, + ) => + points.length > 1 + ? points.map((p) => { + const v = pick(p) + return { value: v, label: v === null ? `— · ${when(p.at)}` : `${fmt(v)} · ${when(p.at)}` } + }) + : null + const sizes = data?.quote_sizes ?? [] + const quoteAt = (p: (typeof points)[number] | undefined) => p?.quotes[size] ?? null + + // The book now against then, one row per price either side has. + const levels = new Map() + for (const l of data?.book_then ?? []) + levels.set(l.price, { now: 0, then: l.counter, absorbs: 0 }) + for (const l of data?.book_now ?? []) { + const row = levels.get(l.price) ?? { now: 0, then: 0, absorbs: 0 } + levels.set(l.price, { ...row, now: l.counter, absorbs: l.absorbs }) + } + const depth = [...levels.entries()].sort((a, b) => b[0] - a[0]) + + return ( +
+
+

Bid side

+ + what a {asset} seller can be paid + {data?.recorded_since ? ` · recorded since ${when(data.recorded_since)}` : ''} + +
+
+ {SPANS.map(([h, label]) => ( + + ))} +
+ + {error &&

{error}

} + {!error && !data &&

Reading the bid side…

} + {data && points.length === 0 && ( +

+ Nothing recorded yet. The pool is read once a minute, and its history starts with the + first read. +

+ )} + + {data && last && ( + <> +
+ p.posted, + (v) => `${amount(v)} ${counter}`, + )} + /> + p.held, + (v) => `${amount(v, 2)} ${counter}`, + )} + /> + p.net_swapped, + (v) => `${amount(v, 2)} ${counter}`, + )} + /> + quoteAt(p), + (v) => `${fmtPrice(v)} ${counter} each`, + )} + /> +
+
+ quote for selling{' '} + {sizes.map((s, i) => ( + + ))} +
+ + )} + + {data && depth.length > 0 && ( +
+ + + + + + + + + + + + + {depth.map(([p, l]) => ( + + + + + + + + ))} + +
+ The limit-order bids now and {data.hours} hours ago +
+ Price of {asset} + + Bid {data.book_then_at ? `at ${when(data.book_then_at)}` : 'then'} + Bid nowChange{asset} it absorbs now
{fmtPrice(p)}{l.then ? amount(l.then) : '—'}{l.now ? amount(l.now) : '—'} + {l.now === l.then + ? '—' + : `${l.now > l.then ? '+' : '−'}${amount(Math.abs(l.now - l.then))}`} + {l.absorbs ? amount(l.absorbs, 1) : '—'}
+
+ )} + + {data && data.changes.length > 0 && ( +
+ + + + + + + + + + + + + {data.changes.map((c, i) => ( + + + + + + + + ))} + +
Changes to the bid levels, newest first
+ When + + Price of {asset} + + What happened + BeforeAfter
+ {when(c.at)} + {fmtPrice(c.price)}{CHANGE[c.kind] ?? c.kind}{amount(c.before)}{amount(c.after)}
+
+ )} + +

+ Read from the pool contract once a minute and recorded from the first read, so no history + exists before it. “Taken by a swap” means a bid shrank by no more than the pool + paid out in {counter} in the same minute — consistent with a seller filling it, though the + views cannot prove which swap; a shrink beyond that is “withdrawn”. The quote is + the pool's own computation from its state, not anyone's offer: a seller going + through NEAR Intents also pays a solver's spread, and every swap pays the pool's + fee. +

+
+ ) +} diff --git a/web/src/components/RefDclMarket.tsx b/web/src/components/RefDclMarket.tsx index 141b2c5..7c6efa0 100644 --- a/web/src/components/RefDclMarket.tsx +++ b/web/src/components/RefDclMarket.tsx @@ -33,6 +33,7 @@ import { } from '../lib/format' import { href } from '../lib/routes' import { Field } from './Field' +import { BidSidePanel } from './BidSidePanel' import { SeriesBody } from './PriceChart' const EXPLORER = 'https://nearblocks.io/txns/' @@ -252,6 +253,15 @@ export function RefDclMarket({ ) })} + {pools?.pools.map((p) => ( + + ))} + {pools?.pools.map((p) => (