diff --git a/.sqlx/query-96059d0f7ccb72eb51db5603f94b8df57b3d53185b2d2074c0a9e4731a80eb9d.json b/.sqlx/query-96059d0f7ccb72eb51db5603f94b8df57b3d53185b2d2074c0a9e4731a80eb9d.json new file mode 100644 index 0000000..717d3d0 --- /dev/null +++ b/.sqlx/query-96059d0f7ccb72eb51db5603f94b8df57b3d53185b2d2074c0a9e4731a80eb9d.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select date_trunc('day', at, 'UTC') as \"day!\", direction, counter,\n count(*) filter (where outcome <> 'failed') as \"answered!\",\n count(*) filter (where outcome = 'quoted') as \"quoted!\",\n count(*) filter (where outcome = 'no_liquidity' and solvers_able > 0)\n as \"while_able!\",\n count(*) filter (where outcome = 'failed') as \"failed!\"\n from quote_probe\n where chain = $1 and asset = $2 and at >= $3\n group by 1, 2, 3\n order by 1 desc, 2, 3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "day!", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "direction", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "counter", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "answered!", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "quoted!", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "while_able!", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "failed!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + null, + false, + false, + null, + null, + null, + null + ] + }, + "hash": "96059d0f7ccb72eb51db5603f94b8df57b3d53185b2d2074c0a9e4731a80eb9d" +} diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index cb7c5a9..67019e1 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -5171,12 +5171,35 @@ async fn intents_quotes( // Three days at most: about six thousand probes a day is the ceiling of // what one response should carry. let hours = query.hours.unwrap_or(24).clamp(1, 72); - let since = chrono::Utc::now() - chrono::Duration::hours(i64::from(hours)); - let rows = state + let now = chrono::Utc::now(); + let since = now - chrono::Duration::hours(i64::from(hours)); + // A day before the window too, for pauses: one already running when the + // window opens is measured from where it began, when that is on record. + let lookback = since - chrono::Duration::hours(24); + let all_rows = state .store - .quote_probes(&id, &config.asset, since) + .quote_probes(&id, &config.asset, lookback) .await .map_err(database_unavailable)?; + let pauses = quote_pauses(&state, &config, &all_rows, since).await; + let rows: Vec = + all_rows.into_iter().filter(|r| r.at >= since).collect(); + let days = state + .store + .quote_days(&id, &config.asset, now - chrono::Duration::days(30)) + .await + .map_err(database_unavailable)? + .into_iter() + .map(|d| blackbeard_entities::QuoteDay { + day: d.day, + direction: d.direction, + counter: d.counter, + answered: d.answered.max(0) as u32, + quoted: d.quoted.max(0) as u32, + no_liquidity_while_able: d.while_able.max(0) as u32, + failed: d.failed.max(0) as u32, + }) + .collect(); let big = |s: &Option| s.clone().map(BigUintDec); let probes: Vec = rows .into_iter() @@ -5241,9 +5264,127 @@ async fn intents_quotes( probes, availability, tokens, + pauses, + days, })) } +/// The pauses in each direction and pair, newest first, keeping those that +/// reach into the window (blackbeard/observer#45). +/// +/// Found on the every-minute probes only, the smallest size of each pair: the +/// grid's larger sizes run a quarter-hourly and would make a minute's state +/// depend on which sizes happened to be asked. +async fn quote_pauses( + state: &AppState, + config: &crate::config::IntentsConfig, + rows: &[blackbeard_data::store::QuoteProbeRow], + since: chrono::DateTime, +) -> Vec { + use blackbeard_core::quote_probe::{Observed, PauseEnd, nearest, pauses}; + const MAX_BLIND_MS: i64 = 15 * 60_000; + const NEAR_MS: i64 = 5 * 60_000; + + let decimals = |t: &str| { + let t = t.to_owned(); + async move { + state + .store + .near_token(&t) + .await + .ok() + .flatten() + .map(|(_, d)| d) + } + }; + let smallest = |direction: &str, counter: &str| { + rows.iter() + .filter(|r| r.direction == direction && r.counter == counter) + .filter_map(|r| r.size.parse::().ok()) + .min() + }; + + // The pool's price of the asset, from the probe's own reading for selling + // the smallest size into the first counter's pool. + let mut price: Vec<(i64, f64)> = Vec::new(); + if let Some(first) = config.probe.first() { + if let (Some(ad), Some(cd), Some(size)) = ( + decimals(&config.asset).await, + decimals(&first.counter).await, + smallest("sell", &first.counter), + ) { + let size = size.to_string(); + for r in rows + .iter() + .filter(|r| r.direction == "sell" && r.counter == first.counter && r.size == size) + { + let out = r.pool_out.as_deref().and_then(|o| o.parse::().ok()); + let given = r.size.parse::().ok(); + if let (Some(out), Some(given)) = (out, given) { + if given > 0.0 { + let p = + (out / 10f64.powi(i32::from(cd))) / (given / 10f64.powi(i32::from(ad))); + price.push((r.at.timestamp_millis(), p)); + } + } + } + } + } + + let mut out = Vec::new(); + for pair in &config.probe { + for direction in ["sell", "buy"] { + let Some(size) = smallest(direction, &pair.counter) else { + continue; + }; + let size = size.to_string(); + let observed: Vec = rows + .iter() + .filter(|r| r.direction == direction && r.counter == pair.counter && r.size == size) + .map(|r| Observed { + at_ms: r.at.timestamp_millis(), + answered: r.outcome != "failed", + quoted: r.outcome == "quoted", + solver_able: r.outcome == "no_liquidity" && r.solvers_able.unwrap_or(0) > 0, + }) + .collect(); + for p in pauses(&observed, MAX_BLIND_MS) { + let ms = |m: i64| chrono::DateTime::from_timestamp_millis(m); + let Some(last) = ms(p.last_ms) else { continue }; + if last < since { + continue; + } + let (end, resumed_at) = match p.end { + PauseEnd::Resumed { at_ms } => ("resumed", ms(at_ms)), + PauseEnd::Unobserved => ("unobserved", None), + PauseEnd::Ongoing => ("ongoing", None), + }; + let end_ms = resumed_at + .map(|r| r.timestamp_millis()) + .unwrap_or(p.last_ms); + out.push(blackbeard_entities::QuotePause { + direction: direction.to_owned(), + counter: pair.counter.clone(), + start: ms(p.start_ms).unwrap_or(last), + last, + end: end.to_owned(), + resumed_at, + unquoted: p.unquoted, + solver_able: p.solver_able, + unanswered: p.unanswered, + began_before: p.began_before, + price_start: nearest(&price, p.start_ms, NEAR_MS), + price_end: nearest(&price, end_ms, NEAR_MS), + price_1h: nearest(&price, end_ms + 3_600_000, NEAR_MS), + price_4h: nearest(&price, end_ms + 4 * 3_600_000, 2 * NEAR_MS), + }); + } + } + } + out.sort_by_key(|p| std::cmp::Reverse(p.start)); + out +} + /// `GET /v1/chains/{chain}/tx/{hash}`: one extrinsic by its hash /// (blackbeard/observer#48). /// diff --git a/crates/blackbeard-core/src/quote_probe.rs b/crates/blackbeard-core/src/quote_probe.rs index 8c2d247..1a883fc 100644 --- a/crates/blackbeard-core/src/quote_probe.rs +++ b/crates/blackbeard-core/src/quote_probe.rs @@ -146,6 +146,117 @@ pub fn sizes_due(minute: u64, sizes: &[u128], full_every: u64) -> &[u128] { } } +/// One probe, reduced to what finding pauses needs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Observed { + /// When, in Unix milliseconds. + pub at_ms: i64, + /// Whether the service answered at all. + pub answered: bool, + /// Whether it quoted. + pub quoted: bool, + /// Whether at least one solver alone held enough to fill it. + pub solver_able: bool, +} + +/// How a pause ended. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PauseEnd { + /// A quote came back: `at_ms` is when. + Resumed { + /// When the first quote after the pause came back. + at_ms: i64, + }, + /// The probe stopped getting answers for longer than a pause may span + /// unseen, so whether it went on is not known. + Unobserved, + /// Still unquoted at the last probe. + Ongoing, +} + +/// A run of consecutive unquoted probes in one direction and pair. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Pause { + /// The first unquoted probe. + pub start_ms: i64, + /// The last unquoted probe. + pub last_ms: i64, + /// How it ended. + pub end: PauseEnd, + /// Probes answered with no quote. + pub unquoted: u32, + /// Of those, answered while a solver alone could have filled the trade. + pub solver_able: u32, + /// Probes inside it that got no answer at all. + pub unanswered: u32, + /// Whether it was already under way at the first probe given: its start + /// is then only the start of what was looked at. + pub began_before: bool, +} + +/// Find the pauses in one direction and pair's probes, oldest first. +/// +/// A pause is consecutive unquoted answers. A quote ends it. A probe that got +/// no answer does not, since nothing is known about that minute either way; +/// it is counted inside. But more than `max_blind_ms` without an answer ends +/// the pause as unobserved, because claiming it ran on through a blackout +/// would be claiming something nobody saw. +pub fn pauses(probes: &[Observed], max_blind_ms: i64) -> Vec { + let mut out = Vec::new(); + let mut open: Option = None; + let mut blind_since: Option = None; + for (i, p) in probes.iter().enumerate() { + if !p.answered { + if let Some(pause) = open.as_mut() { + pause.unanswered += 1; + let since = *blind_since.get_or_insert(p.at_ms); + if p.at_ms - since > max_blind_ms { + let mut done = open.take().expect("open"); + done.end = PauseEnd::Unobserved; + out.push(done); + blind_since = None; + } + } + continue; + } + blind_since = None; + if p.quoted { + if let Some(mut done) = open.take() { + done.end = PauseEnd::Resumed { at_ms: p.at_ms }; + out.push(done); + } + continue; + } + let pause = open.get_or_insert(Pause { + start_ms: p.at_ms, + last_ms: p.at_ms, + end: PauseEnd::Ongoing, + unquoted: 0, + solver_able: 0, + unanswered: 0, + began_before: i == 0, + }); + pause.last_ms = p.at_ms; + pause.unquoted += 1; + if p.solver_able { + pause.solver_able += 1; + } + } + out.extend(open); + out +} + +/// The value of a time series nearest `at_ms`, if one lies within +/// `tolerance_ms` of it. For prices before and after a pause: a reading +/// minutes away is the price then; one hours away is not. +pub fn nearest(series: &[(i64, f64)], at_ms: i64, tolerance_ms: i64) -> Option { + series + .iter() + .filter(|(t, _)| (t - at_ms).abs() <= tolerance_ms) + .min_by_key(|(t, _)| (t - at_ms).abs()) + .map(|(_, v)| *v) +} + #[cfg(test)] mod tests { use super::*; @@ -244,4 +355,64 @@ mod tests { assert_eq!(r["recipientType"], "INTENTS"); assert_eq!(r["amount"], "5"); } + + fn obs(min: i64, answered: bool, quoted: bool, able: bool) -> Observed { + Observed { + at_ms: min * 60_000, + answered, + quoted, + solver_able: able, + } + } + + #[test] + fn a_quote_ends_a_pause_and_unanswered_minutes_count_inside_it() { + let probes = [ + obs(0, true, true, true), + obs(1, true, false, true), + obs(2, false, false, false), + obs(3, true, false, false), + obs(4, true, true, true), + ]; + let p = pauses(&probes, 15 * 60_000); + assert_eq!(p.len(), 1); + assert_eq!((p[0].start_ms, p[0].last_ms), (60_000, 180_000)); + assert_eq!(p[0].end, PauseEnd::Resumed { at_ms: 240_000 }); + assert_eq!( + (p[0].unquoted, p[0].solver_able, p[0].unanswered), + (2, 1, 1) + ); + assert!(!p[0].began_before); + } + + #[test] + fn a_long_blackout_ends_a_pause_as_unobserved() { + let mut probes = vec![obs(0, true, false, true)]; + probes.extend((1..=20).map(|m| obs(m, false, false, false))); + probes.push(obs(21, true, false, true)); + let p = pauses(&probes, 15 * 60_000); + assert_eq!(p.len(), 2); + assert_eq!(p[0].end, PauseEnd::Unobserved); + assert!(p[0].began_before); + // After the blackout a new pause begins; it is still open. + assert_eq!(p[1].end, PauseEnd::Ongoing); + assert_eq!(p[1].start_ms, 21 * 60_000); + } + + #[test] + fn unanswered_probes_outside_a_pause_start_nothing() { + let probes = [ + obs(0, true, true, true), + obs(1, false, false, false), + obs(2, true, true, true), + ]; + assert!(pauses(&probes, 15 * 60_000).is_empty()); + } + + #[test] + fn a_price_is_taken_only_from_a_reading_close_enough() { + let series = [(0, 100.0), (600_000, 101.0)]; + assert_eq!(nearest(&series, 590_000, 300_000), Some(101.0)); + assert_eq!(nearest(&series, 3_600_000, 300_000), None); + } } diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index cbc182e..d39b3d3 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -4684,3 +4684,65 @@ impl Store { Ok(h.map(|h| h.max(0) as u64)) } } + +/// One UTC day of probes for one direction and counter. +#[derive(Debug, Clone)] +pub struct QuoteDayRow { + /// UTC midnight. + pub day: DateTime, + /// `sell` or `buy`. + pub direction: String, + /// The counter token. + pub counter: String, + /// Answered. + pub answered: i64, + /// Quoted. + pub quoted: i64, + /// No liquidity while a solver could fill. + pub while_able: i64, + /// Unanswered. + pub failed: i64, +} + +impl Store { + /// Probe outcomes per UTC day since `since`, newest day first + /// (blackbeard/observer#45). + pub async fn quote_days( + &self, + chain: &ChainId, + asset: &str, + since: DateTime, + ) -> Result, DataError> { + let rows = sqlx::query!( + r#" + select date_trunc('day', at, 'UTC') as "day!", direction, counter, + count(*) filter (where outcome <> 'failed') as "answered!", + count(*) filter (where outcome = 'quoted') as "quoted!", + count(*) filter (where outcome = 'no_liquidity' and solvers_able > 0) + as "while_able!", + count(*) filter (where outcome = 'failed') as "failed!" + from quote_probe + where chain = $1 and asset = $2 and at >= $3 + group by 1, 2, 3 + order by 1 desc, 2, 3 + "#, + chain.as_str(), + asset, + since, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| QuoteDayRow { + day: r.day, + direction: r.direction, + counter: r.counter, + answered: r.answered, + quoted: r.quoted, + while_able: r.while_able, + failed: r.failed, + }) + .collect()) + } +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index c317191..ad7f709 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -58,7 +58,7 @@ pub use miner::{ pub use near::{ IntentsAccount, IntentsAccountEvent, IntentsBalance, NearAction, NearAmount, NearEvent, NearMemo, NearPart, NearReceipt, NearToken, NearTx, NearTxSource, QuantusTxRef, - QuoteAvailability, QuoteProbe, QuoteProbes, + QuoteAvailability, QuoteDay, QuotePause, QuoteProbe, QuoteProbes, }; pub use reversible::{PendingTransfer, ReversibleState}; pub use roles::{AccountRoleEntry, ChainRoles, GenesisDetail, NamedAccount, RoleSource}; diff --git a/crates/blackbeard-entities/src/near.rs b/crates/blackbeard-entities/src/near.rs index e387fd0..39b3c68 100644 --- a/crates/blackbeard-entities/src/near.rs +++ b/crates/blackbeard-entities/src/near.rs @@ -416,4 +416,70 @@ pub struct QuoteProbes { pub availability: Vec, /// Display facts for the asset and every counter. pub tokens: Vec, + /// Runs of consecutive unquoted minutes, newest first (blackbeard/observer#45). + pub pauses: Vec, + /// Per UTC day, per direction and counter, over the last month. + pub days: Vec, +} + +/// A run of consecutive unquoted minutes in one direction and pair. +/// +/// A quote ends it. A probe that got no answer does not, since nothing is known +/// about that minute, but more than a quarter hour without one ends it as +/// unobserved rather than claiming it ran on unseen. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "QuotePause.ts")] +pub struct QuotePause { + /// `sell` or `buy`. + pub direction: String, + /// The counter token. + pub counter: String, + /// The first unquoted probe. + pub start: DateTime, + /// The last unquoted probe. + pub last: DateTime, + /// `resumed` (a quote came back, at `resumed_at`), `unobserved` (the probe + /// lost its answers for too long to say), or `ongoing`. + pub end: String, + /// When quoting resumed. + pub resumed_at: Option>, + /// Probes answered with no quote. + pub unquoted: u32, + /// Of those, answered while a solver alone could have filled the trade. + pub solver_able: u32, + /// Probes inside it that got no answer. + pub unanswered: u32, + /// Already under way when the record this was computed from begins, so its + /// true start is earlier. + pub began_before: bool, + /// The pool's price of the asset in the first counter token, at the start, + /// at the end, and an hour and four hours after: read from the pool by the + /// probe itself, not quoted by anyone. + pub price_start: Option, + /// At the end. + pub price_end: Option, + /// An hour after the end. + pub price_1h: Option, + /// Four hours after the end. + pub price_4h: Option, +} + +/// One UTC day of probes for one direction and counter. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "QuoteDay.ts")] +pub struct QuoteDay { + /// The day, at UTC midnight. + pub day: DateTime, + /// `sell` or `buy`. + pub direction: String, + /// The counter token. + pub counter: String, + /// Probes answered. + pub answered: u32, + /// Quoted. + pub quoted: u32, + /// No quote while a solver alone could have filled it. + pub no_liquidity_while_able: u32, + /// Unanswered. + pub failed: u32, } diff --git a/web/src/api/generated/QuoteDay.ts b/web/src/api/generated/QuoteDay.ts new file mode 100644 index 0000000..516c59e --- /dev/null +++ b/web/src/api/generated/QuoteDay.ts @@ -0,0 +1,34 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One UTC day of probes for one direction and counter. + */ +export type QuoteDay = { +/** + * The day, at UTC midnight. + */ +day: string, +/** + * `sell` or `buy`. + */ +direction: string, +/** + * The counter token. + */ +counter: string, +/** + * Probes answered. + */ +answered: number, +/** + * Quoted. + */ +quoted: number, +/** + * No quote while a solver alone could have filled it. + */ +no_liquidity_while_able: number, +/** + * Unanswered. + */ +failed: number, }; diff --git a/web/src/api/generated/QuotePause.ts b/web/src/api/generated/QuotePause.ts new file mode 100644 index 0000000..7325df8 --- /dev/null +++ b/web/src/api/generated/QuotePause.ts @@ -0,0 +1,70 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A run of consecutive unquoted minutes in one direction and pair. + * + * A quote ends it. A probe that got no answer does not, since nothing is known + * about that minute, but more than a quarter hour without one ends it as + * unobserved rather than claiming it ran on unseen. + */ +export type QuotePause = { +/** + * `sell` or `buy`. + */ +direction: string, +/** + * The counter token. + */ +counter: string, +/** + * The first unquoted probe. + */ +start: string, +/** + * The last unquoted probe. + */ +last: string, +/** + * `resumed` (a quote came back, at `resumed_at`), `unobserved` (the probe + * lost its answers for too long to say), or `ongoing`. + */ +end: string, +/** + * When quoting resumed. + */ +resumed_at: string | null, +/** + * Probes answered with no quote. + */ +unquoted: number, +/** + * Of those, answered while a solver alone could have filled the trade. + */ +solver_able: number, +/** + * Probes inside it that got no answer. + */ +unanswered: number, +/** + * Already under way when the record this was computed from begins, so its + * true start is earlier. + */ +began_before: boolean, +/** + * The pool's price of the asset in the first counter token, at the start, + * at the end, and an hour and four hours after: read from the pool by the + * probe itself, not quoted by anyone. + */ +price_start: number | null, +/** + * At the end. + */ +price_end: number | null, +/** + * An hour after the end. + */ +price_1h: number | null, +/** + * Four hours after the end. + */ +price_4h: number | null, }; diff --git a/web/src/api/generated/QuoteProbes.ts b/web/src/api/generated/QuoteProbes.ts index 6d45e3a..850bdc8 100644 --- a/web/src/api/generated/QuoteProbes.ts +++ b/web/src/api/generated/QuoteProbes.ts @@ -2,6 +2,8 @@ import type { ChainId } from "./ChainId"; import type { NearToken } from "./NearToken"; import type { QuoteAvailability } from "./QuoteAvailability"; +import type { QuoteDay } from "./QuoteDay"; +import type { QuotePause } from "./QuotePause"; import type { QuoteProbe } from "./QuoteProbe"; /** @@ -35,4 +37,12 @@ availability: Array, /** * Display facts for the asset and every counter. */ -tokens: Array, }; +tokens: Array, +/** + * Runs of consecutive unquoted minutes, newest first (blackbeard/observer#45). + */ +pauses: Array, +/** + * Per UTC day, per direction and counter, over the last month. + */ +days: Array, }; diff --git a/web/src/components/QuotePanel.tsx b/web/src/components/QuotePanel.tsx index 748ace5..9907e02 100644 --- a/web/src/components/QuotePanel.tsx +++ b/web/src/components/QuotePanel.tsx @@ -24,10 +24,11 @@ import { useEffect, useMemo, useState } from 'react' import type { QuoteAvailability } from '../api/generated/QuoteAvailability' +import type { QuotePause } from '../api/generated/QuotePause' import type { QuoteProbe } from '../api/generated/QuoteProbe' import type { QuoteProbes } from '../api/generated/QuoteProbes' import { RequestFailed, fetchQuoteProbes } from '../api/rest' -import { tokens as fmtTokens, utc, when, zone } from '../lib/format' +import { price as fmtPrice, tokens as fmtTokens, utc, utcDay, when, zone } from '../lib/format' import { shortNear } from '../lib/near' import { tokenLabel } from '../lib/tokens' @@ -269,6 +270,47 @@ export function QuotePanel({ chain, symbol: chainSymbol }: { chain: string; symb + + + {data.pauses.length > 0 && ( +
+ + + + + + + + + + + + + + {data.pauses.slice(0, 12).map((p) => ( + + ))} + +
+ Stretches with no quote, newest first, with what could have filled them and the + pool's price around them +
+ No quote for + + From + + Until + LengthA solver could fill + Pool price ({data.tokens[1] ? label(data.tokens[1].token) : 'counter'}) +
+
+ )} + {incidents.length > 0 && (
@@ -318,6 +360,51 @@ export function QuotePanel({ chain, symbol: chainSymbol }: { chain: string; symb )} + {data && data.days.length > 0 && ( +
+
+ + + + + + + + + + + + + {data.days.map((d) => ( + + + + + + + + + ))} + +
Probe outcomes per UTC day, last 30 days
+ Day (UTC) + + Trade + AnsweredQuotedNo quote while a solver could fillNo answer
{utcDay(d.day, true)} + {d.direction} {symbol} · {label(d.counter)} + {d.answered}{share(d.quoted, d.answered)}{share(d.no_liquidity_while_able, d.answered)}{d.failed}
+
+ )} + +

+ A stretch with no quote begins with the first minute answered without a quote, and ends when + a quote comes back. A minute the probe got no answer for is counted inside it rather than + ending it, but more than a quarter hour without answers ends it as unobserved: claiming it + ran on unseen would claim something nobody saw. The pool's price around each stretch is + the probe's own reading of the pool, not anyone's quote, and a price that moved + after a stretch is not evidence of why the stretch happened. +

+

Each minute the observer asks 1Click for a dry quote — nothing is committed — selling and buying {symbol} at the smallest size, and every size each quarter hour, without a key and @@ -435,3 +522,93 @@ function strips(data: QuoteProbes | null): Strip[] { } return out } + +/** How a stretch's length reads: to the quote that ended it, or to now. */ +function span(p: QuotePause): string { + const end = Date.parse(p.resumed_at ?? p.last) + const minutes = Math.max(1, Math.round((end - Date.parse(p.start)) / 60_000)) + if (minutes < 120) return `${minutes} min` + const h = Math.floor(minutes / 60) + return `${h} h ${minutes % 60} min` +} + +function PauseRow({ + p, + label, + symbol, +}: { + p: QuotePause + label: (token: string) => string + symbol: string +}) { + const until = + p.end === 'resumed' && p.resumed_at + ? when(p.resumed_at) + : p.end === 'ongoing' + ? 'still unquoted' + : `${when(p.last)}, then unobserved` + const prices = [p.price_start, p.price_end, p.price_1h, p.price_4h] + .map((v) => (v === null ? '—' : fmtPrice(v))) + .join(' → ') + return ( + + + {p.direction === 'sell' ? 'selling' : 'buying'} {symbol} · {label(p.counter)} + + + {p.began_before ? 'by ' : ''} + {when(p.start)} + + + {until} + + + {p.began_before ? '≥ ' : ''} + {span(p)} + + + {p.solver_able} of {p.unquoted} min + {p.unanswered > 0 && · {p.unanswered} unanswered} + + + {prices} + + + ) +} + +/** + * Buy availability against sell availability, per counter: the gap the + * launch evening made plain, when buying was quoted and selling never was. + */ +function Asymmetry({ data, label }: { data: QuoteProbes; label: (token: string) => string }) { + const counters = [...new Set(data.availability.map((a) => a.counter))] + const rate = (a: QuoteAvailability | undefined) => + a && a.answered > 0 ? (a.quoted / a.answered) * 100 : null + const lines = counters.flatMap((c) => { + const buy = rate(data.availability.find((a) => a.counter === c && a.direction === 'buy')) + const sell = rate(data.availability.find((a) => a.counter === c && a.direction === 'sell')) + if (buy === null || sell === null) return [] + const gap = Math.round(buy - sell) + return [ + `${label(c)}: buying quoted ${Math.round(buy)}% of answered probes, selling ${Math.round(sell)}% — ${ + gap === 0 + ? 'no gap' + : `${gap > 0 ? '+' : '−'}${Math.abs(gap)} points ${gap > 0 ? 'towards buyers' : 'towards sellers'}` + }`, + ] + }) + if (lines.length === 0) return null + return ( +

+ {lines.map((l) => ( + + {l} + + ))} +

+ ) +}