diff --git a/.sqlx/query-8c400b39e4fead228447f1e63c5421a0d58d00518148c2114c68619cea9362e3.json b/.sqlx/query-8c400b39e4fead228447f1e63c5421a0d58d00518148c2114c68619cea9362e3.json new file mode 100644 index 0000000..1c6fd9b --- /dev/null +++ b/.sqlx/query-8c400b39e4fead228447f1e63c5421a0d58d00518148c2114c68619cea9362e3.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select at, asset_amount::text as \"asset_amount!\",\n counter_amount::text as \"counter_amount!\", kind\n from intents_trade\n where chain = $1 and asset = $2 and counter_token = $3\n order by near_height desc, trade_index desc\n limit $4\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "at", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "asset_amount!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "counter_amount!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "kind", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [ + false, + null, + null, + false + ] + }, + "hash": "8c400b39e4fead228447f1e63c5421a0d58d00518148c2114c68619cea9362e3" +} diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index f3f8f08..ed39202 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -65,6 +65,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .route("/v1/chains/{chain}/markets", get(markets)) .route("/v1/chains/{chain}/intents/swaps", get(intents_swaps)) .route("/v1/chains/{chain}/intents/price", get(intents_price)) + .route("/v1/chains/{chain}/intents/series", get(intents_series)) .route("/v1/chains/{chain}/nodes", get(nodes)) .route("/v1/chains/{chain}/nodes/{peer}", get(node)) .route("/v1/chains/{chain}/accounts", get(accounts)) @@ -2009,6 +2010,122 @@ async fn intents_price( })) } +/// Trades a price series is taken over, by default and at most. +const PRICE_SERIES_TRADES: i64 = 500; +const PRICE_SERIES_TRADES_MAX: i64 = 5000; +/// Buckets in a price series, by default and at most. +const PRICE_SERIES_POINTS: usize = 48; +const PRICE_SERIES_POINTS_MAX: usize = 200; +/// The narrowest bucket: a burst of trades seconds apart draws as one point, +/// not as a day's worth of one-second buckets. +const PRICE_SERIES_MIN_BUCKET_MS: i64 = 60_000; + +#[derive(Debug, Deserialize)] +struct SeriesQuery { + /// The counter token, as a multi-token id. + counter: Option, + trades: Option, + points: Option, +} + +/// The asset's price in one counter token, bucketed over its latest trades. +/// +/// One counter per request: prices in different tokens share no axis, and a +/// chart of all of them at once would be one hue per token. blackbeard/observer#33. +async fn intents_series( + State(state): State, + Path(chain): Path, + Query(query): Query, +) -> Result, Failure> { + 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 counter = query.counter.filter(|c| !c.is_empty()).ok_or_else(|| { + Failure( + StatusCode::BAD_REQUEST, + ApiError::new("no_counter", "name the counter token: `counter=nep141:…`"), + ) + })?; + let window = query + .trades + .unwrap_or(PRICE_SERIES_TRADES) + .clamp(1, PRICE_SERIES_TRADES_MAX); + let points = query + .points + .unwrap_or(PRICE_SERIES_POINTS) + .clamp(1, PRICE_SERIES_POINTS_MAX); + + let rows = state + .store + .intent_series_trades(&id, &config.asset, &counter, window) + .await + .map_err(database_unavailable)?; + let scan = state + .store + .intents_scan(&id, &config.asset) + .await + .map_err(database_unavailable)?; + let tokens = near_tokens(&state.store, vec![config.asset.clone(), counter.clone()]).await?; + let asset_meta = tokens.get(&config.asset).cloned(); + let counter_meta = tokens.get(&counter).cloned(); + + let trades: Vec = rows + .iter() + .filter_map(|(at, a, c, kind)| { + Some(blackbeard_core::intents::SeriesTrade { + at_ms: at.timestamp_millis(), + asset: a.parse().ok()?, + counter: c.parse().ok()?, + taker: kind == "taker", + }) + }) + .collect(); + // Without both decimal counts there is no price to draw: the bucket list + // stays empty and the page says why, rather than plotting raw base units. + let (width, buckets) = match (&asset_meta, &counter_meta) { + (Some((_, ad)), Some((_, cd))) => blackbeard_core::intents::bucket_prices( + &trades, + *ad, + *cd, + points, + PRICE_SERIES_MIN_BUCKET_MS, + ), + _ => (PRICE_SERIES_MIN_BUCKET_MS, Vec::new()), + }; + + Ok(Json(blackbeard_entities::IntentsSeries { + chain: id, + asset: config.asset, + asset_symbol: asset_meta.as_ref().map(|m| m.0.clone()), + asset_decimals: asset_meta.map(|m| m.1), + counter_symbol: counter_meta.map(|m| m.0), + counter_token: counter, + window_trades: window as u32, + trades: trades.len() as u32, + bucket_seconds: (width / 1000).max(1) as u64, + points: buckets + .into_iter() + .filter_map(|b| { + Some(blackbeard_entities::IntentsSeriesPoint { + at: chrono::DateTime::from_timestamp_millis(b.start_ms)?, + trades: b.trades, + taker_trades: b.taker_trades, + vwap: b.vwap, + low: b.low, + high: b.high, + asset_volume: BigUintDec(b.asset_volume.to_string()), + }) + }) + .collect(), + indexed_from: scan.map(|(low, _)| low), + indexed_to: scan.map(|(_, high)| high), + })) +} + /// Paging cursor for the block index. #[derive(Debug, Deserialize)] struct BeforeQuery { diff --git a/crates/blackbeard-core/src/intents.rs b/crates/blackbeard-core/src/intents.rs index fdc70b3..fe1a026 100644 --- a/crates/blackbeard-core/src/intents.rs +++ b/crates/blackbeard-core/src/intents.rs @@ -342,6 +342,125 @@ pub fn vwap(trades: &[(u128, u128)], asset_decimals: u8, counter_decimals: u8) - (asset > 0.0).then(|| counter / asset) } +/// One trade, as the price series reads it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SeriesTrade { + /// When it settled, milliseconds since the epoch. + pub at_ms: i64, + /// Asset that changed hands, base units. + pub asset: u128, + /// Counter token that changed hands, base units. + pub counter: u128, + /// A user's trade rather than solvers trading with each other. + pub taker: bool, +} + +/// Every trade that settled in one time bucket. +#[derive(Debug, Clone, PartialEq)] +pub struct PriceBucket { + /// The bucket's start, milliseconds since the epoch. + pub start_ms: i64, + /// Trades in it. + pub trades: u32, + /// How many of them were users'. + pub taker_trades: u32, + /// Volume-weighted: summed counter over summed asset, in whole units. + pub vwap: f64, + /// The cheapest single trade in it. + pub low: f64, + /// The dearest. + pub high: f64, + /// Asset traded in it, base units. + pub asset_volume: u128, +} + +/// Bucket trades into at most `points` equal spans of time, for a price chart. +/// +/// The span is whatever the trades cover — the window is a count of trades, as +/// a window elsewhere on this site is a count of blocks, so the time axis is a +/// consequence of the data and can never claim more than it holds. A bucket +/// never narrows below `min_bucket_ms`: a burst of trades a second apart would +/// otherwise draw as forty-eight one-second buckets and read as a day. +/// +/// **Empty buckets are omitted, not zero-filled and not interpolated.** A +/// price chart that joined two trades hours apart would draw a path nobody +/// traded at; leaving the bucket out lets the chart break its line there. +/// +/// Returns the bucket width and the non-empty buckets, oldest first. +pub fn bucket_prices( + trades: &[SeriesTrade], + asset_decimals: u8, + counter_decimals: u8, + points: usize, + min_bucket_ms: i64, +) -> (i64, Vec) { + let priced: Vec<&SeriesTrade> = trades + .iter() + .filter(|t| t.asset > 0 && t.counter > 0) + .collect(); + let (Some(t0), Some(t1)) = ( + priced.iter().map(|t| t.at_ms).min(), + priced.iter().map(|t| t.at_ms).max(), + ) else { + return (min_bucket_ms.max(1), Vec::new()); + }; + let points = points.max(1) as i64; + // Ceiling division, so the newest trade lands in the last bucket rather + // than one past it. + // Rounded up to a whole second, so the width the API reports in seconds is + // the width the buckets actually have — a reader that tests "are these two + // buckets adjacent" against a truncated width finds none of them adjacent. + let width = ((t1 - t0 + points) / points).max(min_bucket_ms).max(1); + let width = (width + 999) / 1000 * 1000; + + /// What a bucket accumulates before it becomes a [`PriceBucket`]. + struct Acc { + trades: u32, + taker_trades: u32, + asset: f64, + counter: f64, + low: f64, + high: f64, + volume: u128, + } + + let mut buckets: BTreeMap = BTreeMap::new(); + for t in priced { + let a = scaled(t.asset, asset_decimals); + let c = scaled(t.counter, counter_decimals); + let p = c / a; + let acc = buckets.entry((t.at_ms - t0) / width).or_insert(Acc { + trades: 0, + taker_trades: 0, + asset: 0.0, + counter: 0.0, + low: f64::INFINITY, + high: f64::NEG_INFINITY, + volume: 0, + }); + acc.trades += 1; + acc.taker_trades += u32::from(t.taker); + acc.asset += a; + acc.counter += c; + acc.low = acc.low.min(p); + acc.high = acc.high.max(p); + acc.volume = acc.volume.saturating_add(t.asset); + } + let out = buckets + .into_iter() + .map(|(index, acc)| PriceBucket { + start_ms: t0 + index * width, + trades: acc.trades, + taker_trades: acc.taker_trades, + vwap: acc.counter / acc.asset, + low: acc.low, + high: acc.high, + asset_volume: acc.volume, + }) + .collect(); + (width, out) +} + #[cfg(test)] mod tests { use super::*; @@ -578,4 +697,74 @@ mod tests { assert!((v - 19.0).abs() < 1e-9); assert_eq!(vwap(&[], 0, 0), None); } + fn trade(at_ms: i64, asset: u128, counter: u128) -> SeriesTrade { + SeriesTrade { + at_ms, + asset, + counter, + taker: true, + } + } + + #[test] + fn no_trades_is_no_buckets() { + let (width, buckets) = bucket_prices(&[], 0, 0, 48, 60_000); + assert_eq!(width, 60_000); + assert!(buckets.is_empty()); + } + + #[test] + fn a_bucket_is_volume_weighted_with_its_range() { + // 1 at 10 and 9 at 20, in one bucket: VWAP 19, low 10, high 20. + let (_, b) = bucket_prices(&[trade(0, 1, 10), trade(1_000, 9, 180)], 0, 0, 48, 60_000); + assert_eq!(b.len(), 1); + assert!((b[0].vwap - 19.0).abs() < 1e-9); + assert_eq!((b[0].low, b[0].high), (10.0, 20.0)); + assert_eq!((b[0].trades, b[0].asset_volume), (2, 10)); + } + + #[test] + fn empty_buckets_are_left_out_so_the_line_can_break() { + // Two trades a day apart, 48 buckets: a bucket each, nothing between. + let day = 86_400_000; + let (width, b) = bucket_prices(&[trade(0, 1, 5), trade(day, 1, 6)], 0, 0, 48, 60_000); + assert_eq!(width % 1000, 0, "a whole number of seconds"); + assert!( + width > day / 48 && width - day / 48 <= 1000, + "within a second of exact" + ); + assert_eq!(b.len(), 2); + assert_eq!(b[0].start_ms, 0); + assert_eq!( + b[1].start_ms, + 47 * width, + "the newest trade is in the last bucket, not past it" + ); + } + + #[test] + fn adjacent_buckets_are_exactly_one_width_apart() { + let trades: Vec<_> = (0..100).map(|i| trade(i * 7_013, 1, 2)).collect(); + let (width, b) = bucket_prices(&trades, 0, 0, 48, 60_000); + assert!( + b.windows(2) + .all(|w| (w[1].start_ms - w[0].start_ms) % width == 0) + ); + assert_eq!(width % 1000, 0); + } + + #[test] + fn a_burst_does_not_collapse_to_one_second_buckets() { + let burst: Vec<_> = (0..10).map(|i| trade(i * 1_000, 1, 2)).collect(); + let (width, b) = bucket_prices(&burst, 0, 0, 48, 60_000); + assert_eq!(width, 60_000); + assert_eq!(b.len(), 1); + } + + #[test] + fn decimals_are_applied_per_token() { + // The captured swap: 7.02585188 ZEC (8 decimals) for 9,036.3 USDT (6). + let (_, b) = bucket_prices(&[trade(0, 702_585_188, 9_036_300_000)], 8, 6, 48, 60_000); + assert!((b[0].vwap - 1286.15).abs() < 0.01, "{}", b[0].vwap); + } } diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index fae8020..1cadbdc 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -2946,6 +2946,38 @@ impl Store { .collect()) } + /// The latest `limit` trades of `asset` against one counter token, as + /// `(at, asset_amount, counter_amount, kind)`, newest first. What the price + /// chart buckets. + pub async fn intent_series_trades( + &self, + chain: &ChainId, + asset: &str, + counter: &str, + limit: i64, + ) -> Result, String, String, String)>, DataError> { + let rows = sqlx::query!( + r#" + select at, asset_amount::text as "asset_amount!", + counter_amount::text as "counter_amount!", kind + from intents_trade + where chain = $1 and asset = $2 and counter_token = $3 + order by near_height desc, trade_index desc + limit $4 + "#, + chain.as_str(), + asset, + counter, + limit, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| (r.at, r.asset_amount, r.counter_amount, r.kind)) + .collect()) + } + /// The latest `per_counter` trades against each counter token, newest /// first within each. /// diff --git a/crates/blackbeard-entities/src/intents.rs b/crates/blackbeard-entities/src/intents.rs index 2060f75..def7458 100644 --- a/crates/blackbeard-entities/src/intents.rs +++ b/crates/blackbeard-entities/src/intents.rs @@ -197,3 +197,60 @@ pub struct Markets { /// Its venues. pub markets: Vec, } + +/// One time bucket of a price series: every trade that settled in it. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "IntentsSeriesPoint.ts")] +pub struct IntentsSeriesPoint { + /// The bucket's start. + pub at: DateTime, + /// Trades in it. + pub trades: u32, + /// How many were users' rather than solvers trading with each other. + pub taker_trades: u32, + /// Volume-weighted price, counter tokens per one whole asset. + pub vwap: f64, + /// The cheapest single trade in the bucket. + pub low: f64, + /// The dearest. + pub high: f64, + /// Asset traded in the bucket, base units. + pub asset_volume: BigUintDec, +} + +/// The asset's price in one counter token over its latest trades. +/// +/// Only buckets with trades in them: an empty one is left out rather than +/// zero-filled or interpolated, so a chart breaks its line where nothing +/// traded instead of drawing a price nobody paid. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "IntentsSeries.ts")] +pub struct IntentsSeries { + /// Which chain's token. + pub chain: ChainId, + /// The tracked asset. + pub asset: String, + /// Its own symbol, when read. + pub asset_symbol: Option, + /// Its decimals, when read. + pub asset_decimals: Option, + /// The counter token the prices are in. + pub counter_token: String, + /// Its own symbol, when read. Display only. + pub counter_symbol: Option, + /// Trades the series is taken over, at most. A count, not a duration. + pub window_trades: u32, + /// Trades actually found. + pub trades: u32, + /// Width of each bucket, seconds. + #[ts(type = "number")] + pub bucket_seconds: u64, + /// The buckets with trades in them, oldest first. + pub points: Vec, + /// Lowest NEAR height read. + #[ts(type = "number")] + pub indexed_from: Option, + /// Highest NEAR height read. + #[ts(type = "number")] + pub indexed_to: Option, +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index f10f032..349f8ca 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -40,7 +40,8 @@ pub use chain::{ }; pub use error::{ApiError, EntityError}; pub use intents::{ - CounterPrice, IntentsListing, IntentsPrice, IntentsSwap, IntentsSwaps, MarketSummary, Markets, + CounterPrice, IntentsListing, IntentsPrice, IntentsSeries, IntentsSeriesPoint, IntentsSwap, + IntentsSwaps, MarketSummary, Markets, }; pub use miner::{ AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, NodeActivity, diff --git a/web/src/api/generated/IntentsSeries.ts b/web/src/api/generated/IntentsSeries.ts new file mode 100644 index 0000000..a436987 --- /dev/null +++ b/web/src/api/generated/IntentsSeries.ts @@ -0,0 +1,60 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChainId } from "./ChainId"; +import type { IntentsSeriesPoint } from "./IntentsSeriesPoint"; + +/** + * The asset's price in one counter token over its latest trades. + * + * Only buckets with trades in them: an empty one is left out rather than + * zero-filled or interpolated, so a chart breaks its line where nothing + * traded instead of drawing a price nobody paid. + */ +export type IntentsSeries = { +/** + * Which chain's token. + */ +chain: ChainId, +/** + * The tracked asset. + */ +asset: string, +/** + * Its own symbol, when read. + */ +asset_symbol: string | null, +/** + * Its decimals, when read. + */ +asset_decimals: number | null, +/** + * The counter token the prices are in. + */ +counter_token: string, +/** + * Its own symbol, when read. Display only. + */ +counter_symbol: string | null, +/** + * Trades the series is taken over, at most. A count, not a duration. + */ +window_trades: number, +/** + * Trades actually found. + */ +trades: number, +/** + * Width of each bucket, seconds. + */ +bucket_seconds: number, +/** + * The buckets with trades in them, oldest first. + */ +points: Array, +/** + * Lowest NEAR height read. + */ +indexed_from: number, +/** + * Highest NEAR height read. + */ +indexed_to: number, }; diff --git a/web/src/api/generated/IntentsSeriesPoint.ts b/web/src/api/generated/IntentsSeriesPoint.ts new file mode 100644 index 0000000..19844db --- /dev/null +++ b/web/src/api/generated/IntentsSeriesPoint.ts @@ -0,0 +1,35 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BigUintDec } from "./BigUintDec"; + +/** + * One time bucket of a price series: every trade that settled in it. + */ +export type IntentsSeriesPoint = { +/** + * The bucket's start. + */ +at: string, +/** + * Trades in it. + */ +trades: number, +/** + * How many were users' rather than solvers trading with each other. + */ +taker_trades: number, +/** + * Volume-weighted price, counter tokens per one whole asset. + */ +vwap: number, +/** + * The cheapest single trade in the bucket. + */ +low: number, +/** + * The dearest. + */ +high: number, +/** + * Asset traded in the bucket, base units. + */ +asset_volume: BigUintDec, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index bd54762..d9ad92d 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -18,6 +18,7 @@ import type { ChainRoles } from './generated/ChainRoles' import type { ChainState } from './generated/ChainState' import type { GenesisDetail } from './generated/GenesisDetail' import type { IntentsPrice } from './generated/IntentsPrice' +import type { IntentsSeries } from './generated/IntentsSeries' import type { IntentsSwaps } from './generated/IntentsSwaps' import type { Markets } from './generated/Markets' import type { ReversibleState } from './generated/ReversibleState' @@ -310,3 +311,15 @@ export async function fetchSwapsNewest( const cursor = before ? `&before=${encodeURIComponent(before)}` : '' return get(`/chains/${chain}/intents/swaps?order=newest&limit=50${cursor}`, signal) } + +/** The asset's price in one counter token, bucketed over its latest trades. */ +export async function fetchIntentsSeries( + chain: string, + counter: string, + signal?: AbortSignal, +): Promise { + return get( + `/chains/${chain}/intents/series?counter=${encodeURIComponent(counter)}`, + signal, + ) +} diff --git a/web/src/components/IntentsPanel.tsx b/web/src/components/IntentsPanel.tsx index 55b070d..59302df 100644 --- a/web/src/components/IntentsPanel.tsx +++ b/web/src/components/IntentsPanel.tsx @@ -193,37 +193,44 @@ export function IntentsPanel({ {listingFields} {table && rest.length > 0 && ( -
- - - - - - - - - - - - - {rest.map((c) => ( - - - - - - + // Folded, so the chart below the figures is not pushed under a + // seventy-row table; the summary says how much is inside. +
+ + {fmtHeight(rest.length)} more counter token{rest.length === 1 ? '' : 's'} + +
+
Every other priced counter token
- {asset} in - Volume-weightedLastTrades - Last traded -
- {tokenLabel(c.counter_token, c.symbol)} - {fmtPrice(c.vwap)}{fmtPrice(c.last_price)}{fmtHeight(c.trades)}{ago(c.last_at)}
+ + + + + + + + - ))} - -
Every other priced counter token
+ {asset} in + Volume-weightedLastTrades + Last traded +
-
+ + + {rest.map((c) => ( + + + {tokenLabel(c.counter_token, c.symbol)} + + {fmtPrice(c.vwap)} + {fmtPrice(c.last_price)} + {fmtHeight(c.trades)} + {ago(c.last_at)} + + ))} + + + + )} {(!table && rest.length > 0) || unpriced.length > 0 ? (

diff --git a/web/src/components/MarketPage.tsx b/web/src/components/MarketPage.tsx index a3e83d7..33d1ffa 100644 --- a/web/src/components/MarketPage.tsx +++ b/web/src/components/MarketPage.tsx @@ -21,6 +21,7 @@ import { height as fmtHeight } from '../lib/format' import { useObserverInstance } from '../lib/observer-context' import { href } from '../lib/routes' import { IntentsPanel } from './IntentsPanel' +import { PriceChart } from './PriceChart' import { SwapHead, SwapRow } from './SwapRow' /** The venues this page knows how to show. */ @@ -133,6 +134,8 @@ function NearIntents({ + +

Settling now

diff --git a/web/src/components/PriceChart.tsx b/web/src/components/PriceChart.tsx new file mode 100644 index 0000000..0314cc2 --- /dev/null +++ b/web/src/components/PriceChart.tsx @@ -0,0 +1,435 @@ +/** + * The asset's price over its latest trades, in one counter token at a time. + * + * Read against the `dataviz` skill before it was written (blackbeard/observer#33): + * + * - **One series, one hue, no legend.** The job is trend-over-time for one + * quantity. A token trades against dozens of counters, and one line each + * would be a hue per counter — the categorical blow-up both the skill and + * CLAUDE.md rule out, on an axis the lines would not even share, since each + * price is in a different unit. So the counter is a control above the chart. + * - **Gaps are gaps.** The API leaves out buckets nothing traded in, and the + * line is only drawn between adjacent buckets. Joining two trades hours apart + * would draw a price path nobody traded at; on a thin market that would be + * most of the chart. Every bucket with trades carries a dot, so a lone trade + * is still visible. + * - **The y-axis floats.** A price has no meaningful zero here, and a zero + * baseline would flatten every move into the top pixel. Exact values are in + * the tooltip and the table. + * - **The window is a count of trades.** The time axis spans whatever those + * trades cover, so it cannot claim more than the data holds. + * + * Until anything has traded, the frame keeps its height and draws no axes — a + * scale over no data would invent a range — and says why there is nothing yet. + */ + +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' + +import type { CounterPrice } from '../api/generated/CounterPrice' +import type { IntentsPrice } from '../api/generated/IntentsPrice' +import type { IntentsSeries } from '../api/generated/IntentsSeries' +import type { IntentsSeriesPoint } from '../api/generated/IntentsSeriesPoint' +import { RequestFailed, fetchIntentsPrice, fetchIntentsSeries } from '../api/rest' +import { height as fmtHeight, price as fmtPrice, tokens, when } from '../lib/format' +import { useObserverInstance } from '../lib/observer-context' +import { tokenLabel } from '../lib/tokens' + +const HEIGHT = 220 +const PAD = { top: 16, right: 18, bottom: 26, left: 64 } +const FALLBACK_WIDTH = 720 + +interface Placed { + point: IntentsSeriesPoint + x: number + y: number +} + +/** + * The container's width, measured before the first paint. + * + * Read synchronously in a layout effect, then kept current by a + * ResizeObserver. Waiting for the observer alone paints the chart once at the + * fallback width — 720px, centred in a full-width panel — and then jumps; and + * where the observer is slow to report, that first paint is all there is. + */ +function useMeasuredWidth(): [React.RefObject, number] { + const ref = useRef(null) + const [width, setWidth] = useState(FALLBACK_WIDTH) + useLayoutEffect(() => { + const node = ref.current + if (node) setWidth(Math.max(320, node.getBoundingClientRect().width)) + }, []) + useEffect(() => { + const node = ref.current + if (!node) return + const observer = new ResizeObserver(([entry]) => { + if (entry) setWidth(Math.max(320, entry.contentRect.width)) + }) + observer.observe(node) + return () => observer.disconnect() + }, []) + return [ref, width] +} + +function timeLabel(ms: number, spanMs: number): string { + return new Date(ms).toLocaleString(undefined, { + ...(spanMs > 86_400_000 ? { month: 'short', day: 'numeric' } : {}), + hour: '2-digit', + minute: '2-digit', + }) +} + +/** A bucket width as words: "5 min", "2 h". */ +function span(seconds: number): string { + if (seconds < 3600) return `${Math.round(seconds / 60)} min` + if (seconds < 86_400) return `${(seconds / 3600).toFixed(seconds < 36_000 ? 1 : 0)} h` + return `${(seconds / 86_400).toFixed(1)} d` +} + +/** Counters with a price, most traded first — the ones a chart can be drawn for. */ +function chartable(price: IntentsPrice | null): CounterPrice[] { + return (price?.counters ?? []) + .filter((c) => c.decimals !== null && c.vwap !== null) + .sort((a, b) => b.trades - a.trades) +} + +export function PriceChart({ chain, symbol }: { chain: string; symbol: string }) { + const [price, setPrice] = useState(null) + const [counter, setCounter] = useState(null) + const [series, setSeries] = useState(null) + const [error, setError] = useState(null) + const [settled, setSettled] = useState(0) + const observer = useObserverInstance() + + // A settled swap can be one this chart should show, and can be the first. + useEffect(() => observer.onIntentsSwap(() => setSettled((n) => n + 1)), [observer]) + + useEffect(() => { + const controller = new AbortController() + fetchIntentsPrice(chain, controller.signal) + .then(setPrice) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + return () => controller.abort() + }, [chain, settled]) + + const options = chartable(price) + // Default to the most traded counter; keep the reader's choice once made. + const chosen = counter ?? options[0]?.counter_token ?? null + + useEffect(() => { + if (!chosen) return + const controller = new AbortController() + fetchIntentsSeries(chain, chosen, controller.signal) + .then(setSeries) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + return () => controller.abort() + }, [chain, chosen, settled]) + + const asset = series?.asset_symbol ?? price?.asset_symbol ?? symbol + const unit = chosen + ? tokenLabel(chosen, options.find((c) => c.counter_token === chosen)?.symbol) + : '' + const points = series && series.counter_token === chosen ? series.points : [] + + return ( +
+
+

Price

+ + {series && points.length > 0 + ? `${asset} in ${unit} · latest ${fmtHeight(series.trades)} trades · ${span(series.bucket_seconds)} buckets` + : `${asset} on NEAR Intents`} + +
+ + {options.length > 1 && ( +
+ {' '} + +
+ )} + + {error ? ( +

{error}

+ ) : points.length === 0 ? ( + + ) : ( + + )} + + {points.length > 0 && ( +
+ The figures behind the chart +
+ + + + + + + + + + + + + + {points.map((p) => ( + + + + + + + + + ))} + +
+ {asset} price in {unit} per bucket, oldest first +
+ From + Volume-weightedLowHighTrades{asset}
{when(p.at)}{fmtPrice(p.vwap)}{fmtPrice(p.low)}{fmtPrice(p.high)}{fmtHeight(p.trades)} + {tokens(p.asset_volume, series?.asset_decimals ?? 0)} +
+
+
+ )} + +

+ Each point is one time bucket: the volume-weighted price of every trade that settled in it, + in {unit || 'the counter token'}. The line breaks where nothing traded — it is never drawn + across a gap — and the vertical axis is fitted to the prices, not anchored at zero. The + window is the latest trades against this one token, so the time axis covers only what they + span. +

+
+ ) +} + +/** + * The frame, without a chart in it. Same height as the plot, so nothing below + * moves when the first trade arrives; a baseline and the reason, and no axes, + * because a scale over no data is a range nobody measured. + */ +function Placeholder({ + price, + asset, + loading, +}: { + price: IntentsPrice | null + asset: string + loading: boolean +}) { + const bridged = price?.listing?.total_supply + const reason = loading + ? 'Reading…' + : bridged === '0' + ? `The chart starts with the first settled ${asset} trade. None has: no ${asset} is bridged to NEAR yet, so none can have traded there.` + : `The chart starts with the first settled ${asset} trade with a price. None has settled in the blocks read.` + return ( +
+ + ) +} + +function Plot({ + points, + bucketSeconds, + unit, + asset, +}: { + points: IntentsSeriesPoint[] + bucketSeconds: number + unit: string + asset: string +}) { + const [wrap, width] = useMeasuredWidth() + const [hover, setHover] = useState(null) + const bucketMs = bucketSeconds * 1000 + + const chart = useMemo(() => { + const times = points.map((p) => new Date(p.at).getTime()) + const t0 = Math.min(...times) + // The last bucket's end, so its point does not sit on the right edge. + const t1 = Math.max(...times) + bucketMs + const spanMs = t1 - t0 || 1 + const lo = Math.min(...points.map((p) => p.vwap)) + const hi = Math.max(...points.map((p) => p.vwap)) + // Headroom either side; a flat series gets a band around its one value + // rather than a zero-height axis. + const pad = (hi - lo) * 0.15 || Math.abs(hi) * 0.02 || 1 + const yMin = lo - pad + const yMax = hi + pad + + const plotW = width - PAD.left - PAD.right + const plotH = HEIGHT - PAD.top - PAD.bottom + const baseline = PAD.top + plotH + const x = (t: number) => PAD.left + ((t - t0 + bucketMs / 2) / spanMs) * plotW + const y = (v: number) => baseline - ((v - yMin) / (yMax - yMin)) * plotH + + const placed: Placed[] = points.map((point, i) => ({ + point, + x: x(times[i] ?? t0), + y: y(point.vwap), + })) + + // A segment only between buckets that are next to each other in time. + let path = '' + placed.forEach((p, i) => { + const prev = placed[i - 1] + const adjacent = + prev && new Date(p.point.at).getTime() - new Date(prev.point.at).getTime() <= bucketMs + 1 + path += `${adjacent ? 'L' : 'M'}${p.x.toFixed(1)} ${p.y.toFixed(1)} ` + }) + + const yTicks = [yMin + pad, (yMin + yMax) / 2, yMax - pad].map((v) => ({ v, y: y(v) })) + const xTicks = [0, 0.5, 1].map((f) => ({ t: t0 + spanMs * f, x: PAD.left + f * plotW })) + return { placed, path, yTicks, xTicks, baseline, spanMs, last: placed[placed.length - 1] } + }, [points, width, bucketMs]) + + const onMove = (event: React.PointerEvent) => { + const local = event.clientX - event.currentTarget.getBoundingClientRect().left + let nearest: Placed | null = null + let best = Infinity + for (const p of chart.placed) { + const d = Math.abs(p.x - local) + if (d < best) { + best = d + nearest = p + } + } + setHover(nearest) + } + + return ( +
+ setHover(null)} + > + {chart.yTicks.map((t) => ( + + + + {fmtPrice(t.v)} + + + ))} + {chart.xTicks.map((t, i) => ( + + {timeLabel(t.t, chart.spanMs)} + + ))} + + + {chart.placed.map((p) => ( + + ))} + + {hover && ( + <> + + + + )} + + {/* One direct label, on the newest point: the price a reader came for. */} + {!hover && chart.last && ( + + {fmtPrice(chart.last.point.vwap)} + + )} + + + {hover && ( +
width / 2 ? 'translate(-100%, 0)' : 'translate(10px, 0)', + }} + > +
+ {timeLabel(new Date(hover.point.at).getTime(), chart.spanMs)} · {span(bucketSeconds)} +
+
+ {fmtPrice(hover.point.vwap)} {unit} +
+
+ {hover.point.trades === 1 + ? 'one trade' + : `${fmtPrice(hover.point.low)}–${fmtPrice(hover.point.high)} over ${fmtHeight(hover.point.trades)} trades`} + {hover.point.taker_trades !== hover.point.trades && + `, ${fmtHeight(hover.point.taker_trades)} by users`} +
+
+ )} +
+ ) +}