Skip to content

Commit 6d395da

Browse files
gregorydemayclaude
andauthored
feat(dashboard): render prices and amounts as human-readable floats (#182)
The dev dashboard renders order-book prices and amounts as raw integers, which is hard to read at a glance. This makes the canister dev dashboard show each value as a human-readable float per whole token with unit symbols, in the trader-native `quantity @ price` notation, while keeping the exact raw integers alongside (muted, in parentheses) for debugging. For example a best ask now reads `0.01 ICP @ 90 ckBTC/ICP (1_000_000 @ 9_000_000_000/10^8)`. Scope is the dashboard only — no API or domain changes. - Best bid/ask use the `quantity @ price` form; tick size, lot size and spread render in the same float-with-units style. - Values are scaled by the relevant token's decimals (quantities by base, prices by quote), and the conversion is exact (no float math), so large u256 quantities keep full precision. - The exact raw integers stay visible in muted parentheses next to every value, grouped with `_` thousands separators, with the price divisor (`/10^quote_decimals`) shown so the scaling is verifiable. - Depth tables carry the unit symbol in the column headers to avoid repeating it per row. <img width="1755" height="459" alt="Screenshot 2026-06-26 at 11 15 21" src="https://github.com/user-attachments/assets/56f80640-943e-41a4-994e-1e70ce789205" /> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 087ecf5 commit 6d395da

3 files changed

Lines changed: 170 additions & 67 deletions

File tree

canister/src/dashboard/mod.rs

Lines changed: 86 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ mod tests;
44
use crate::order::{OrderBook, Price, Quantity};
55
use crate::state::State;
66
use askama::Template;
7-
use candid::Principal;
7+
use candid::{Nat, Principal};
88
use ic_stable_structures::Memory;
99
use oisy_trade_types_internal::Mode;
1010

@@ -30,8 +30,9 @@ pub struct DashboardPair {
3030
pub book_id: u64,
3131
pub base_symbol: String,
3232
pub quote_symbol: String,
33-
pub tick_size: u128,
34-
pub lot_size: u64,
33+
pub quote_decimals: u8,
34+
pub tick_size: Amount,
35+
pub lot_size: Amount,
3536
pub maker_fee_bps: u16,
3637
pub taker_fee_bps: u16,
3738
pub bids_len: usize,
@@ -40,13 +41,32 @@ pub struct DashboardPair {
4041
pub resting_orders_len: usize,
4142
pub best_bid: Option<DashboardLevel>,
4243
pub best_ask: Option<DashboardLevel>,
43-
pub spread: Option<u128>,
44+
pub spread: Option<Amount>,
4445
pub depth: DashboardDepth,
4546
}
4647

48+
/// A numeric dashboard field shown both as a human-readable decimal and as its
49+
/// underlying integer.
50+
///
51+
/// For a price `1_000_000_000` at 8 decimals: `decimal_value = "10"`,
52+
/// `raw_value = "1_000_000_000"`.
53+
pub struct Amount {
54+
pub decimal_value: String,
55+
pub raw_value: String,
56+
}
57+
58+
impl Amount {
59+
fn new(raw: Nat, decimals: u8) -> Self {
60+
Self {
61+
decimal_value: format_scaled(&raw.0.to_string(), decimals),
62+
raw_value: raw.to_string(),
63+
}
64+
}
65+
}
66+
4767
pub struct DashboardLevel {
48-
pub price: u128,
49-
pub quantity: String,
68+
pub price: Amount,
69+
pub quantity: Amount,
5070
}
5171

5272
pub struct DashboardDepth {
@@ -61,8 +81,8 @@ impl DashboardDepth {
6181
}
6282

6383
pub struct DashboardDepthLevel {
64-
pub price: u128,
65-
pub quantity: String,
84+
pub price: Amount,
85+
pub quantity: Amount,
6686
pub bar_width_percent: u8,
6787
}
6888

@@ -88,17 +108,19 @@ impl DashboardTemplate {
88108
let book = state
89109
.order_book(book_id)
90110
.expect("BUG: trading pair registered but order book missing");
91-
let base_symbol = state
111+
let base_metadata = state
92112
.token_metadata(&pair.base)
93-
.expect("BUG: base token metadata missing")
94-
.symbol
95-
.clone();
96-
let quote_symbol = state
113+
.expect("BUG: base token metadata missing");
114+
let base_symbol = base_metadata.symbol.clone();
115+
let quote_metadata = state
97116
.token_metadata(&pair.quote)
98-
.expect("BUG: quote token metadata missing")
99-
.symbol
100-
.clone();
101-
build_pair(book.id().get(), base_symbol, quote_symbol, book)
117+
.expect("BUG: quote token metadata missing");
118+
let quote_symbol = quote_metadata.symbol.clone();
119+
let decimals = PairDecimals {
120+
base: base_metadata.decimals,
121+
quote: quote_metadata.decimals,
122+
};
123+
build_pair(book.id().get(), base_symbol, quote_symbol, decimals, book)
102124
})
103125
.collect();
104126
Self {
@@ -111,28 +133,37 @@ impl DashboardTemplate {
111133
}
112134
}
113135

136+
struct PairDecimals {
137+
base: u8,
138+
quote: u8,
139+
}
140+
114141
fn build_pair(
115142
book_id: u64,
116143
base_symbol: String,
117144
quote_symbol: String,
145+
decimals: PairDecimals,
118146
book: &OrderBook,
119147
) -> DashboardPair {
120148
let bids: Vec<(Price, Quantity)> = book.bid_levels(DEPTH_LEVELS).collect();
121149
let asks: Vec<(Price, Quantity)> = book.ask_levels(DEPTH_LEVELS).collect();
122150
let best_bid_level = bids.first().copied();
123151
let best_ask_level = asks.first().copied();
124-
let best_bid = best_bid_level.map(level);
125-
let best_ask = best_ask_level.map(level);
152+
let best_bid = best_bid_level.map(|l| level(l, &decimals));
153+
let best_ask = best_ask_level.map(|l| level(l, &decimals));
126154
let spread = match (best_bid_level, best_ask_level) {
127-
(Some((bid, _)), Some((ask, _))) => ask.checked_sub(bid).map(Price::get),
155+
(Some((bid, _)), Some((ask, _))) => ask
156+
.checked_sub(bid)
157+
.map(|s| Amount::new(Nat::from(s.get()), decimals.quote)),
128158
_ => None,
129159
};
130160
DashboardPair {
131161
book_id,
132162
base_symbol,
133163
quote_symbol,
134-
tick_size: book.tick_size().get(),
135-
lot_size: book.lot_size().get(),
164+
quote_decimals: decimals.quote,
165+
tick_size: Amount::new(Nat::from(book.tick_size().get()), decimals.quote),
166+
lot_size: Amount::new(Nat::from(book.lot_size().get()), decimals.base),
136167
maker_fee_bps: book.fee_rates().maker.get(),
137168
taker_fee_bps: book.fee_rates().taker.get(),
138169
bids_len: book.bids_len(),
@@ -142,29 +173,37 @@ fn build_pair(
142173
best_bid,
143174
best_ask,
144175
spread,
145-
depth: build_depth(&bids, &asks),
176+
depth: build_depth(&bids, &asks, &decimals),
146177
}
147178
}
148179

149-
fn build_depth(bids: &[(Price, Quantity)], asks: &[(Price, Quantity)]) -> DashboardDepth {
180+
fn build_depth(
181+
bids: &[(Price, Quantity)],
182+
asks: &[(Price, Quantity)],
183+
decimals: &PairDecimals,
184+
) -> DashboardDepth {
150185
let max = bids
151186
.iter()
152187
.chain(asks.iter())
153188
.map(|(_, q)| saturating_to_u128(q))
154189
.max()
155190
.unwrap_or(0);
156191
DashboardDepth {
157-
bids: depth_levels(bids, max),
158-
asks: depth_levels(asks, max),
192+
bids: depth_levels(bids, max, decimals),
193+
asks: depth_levels(asks, max, decimals),
159194
}
160195
}
161196

162-
fn depth_levels(levels: &[(Price, Quantity)], max: u128) -> Vec<DashboardDepthLevel> {
197+
fn depth_levels(
198+
levels: &[(Price, Quantity)],
199+
max: u128,
200+
decimals: &PairDecimals,
201+
) -> Vec<DashboardDepthLevel> {
163202
levels
164203
.iter()
165204
.map(|(price, qty)| DashboardDepthLevel {
166-
price: price.get(),
167-
quantity: qty.to_nat().to_string(),
205+
price: Amount::new(Nat::from(price.get()), decimals.quote),
206+
quantity: Amount::new(qty.to_nat(), decimals.base),
168207
bar_width_percent: bar_width_percent(saturating_to_u128(qty), max),
169208
})
170209
.collect()
@@ -181,10 +220,25 @@ fn bar_width_percent(qty: u128, max: u128) -> u8 {
181220
percent.min(100) as u8
182221
}
183222

184-
fn level((price, quantity): (Price, Quantity)) -> DashboardLevel {
223+
fn level((price, quantity): (Price, Quantity), decimals: &PairDecimals) -> DashboardLevel {
185224
DashboardLevel {
186-
price: price.get(),
187-
quantity: quantity.to_nat().to_string(),
225+
price: Amount::new(Nat::from(price.get()), decimals.quote),
226+
quantity: Amount::new(quantity.to_nat(), decimals.base),
227+
}
228+
}
229+
230+
fn format_scaled(raw: &str, decimals: u8) -> String {
231+
let decimals = decimals as usize;
232+
if decimals == 0 {
233+
return raw.to_string();
234+
}
235+
let padded = format!("{:0>width$}", raw, width = decimals + 1);
236+
let split = padded.len() - decimals;
237+
let frac = padded[split..].trim_end_matches('0');
238+
if frac.is_empty() {
239+
padded[..split].to_string()
240+
} else {
241+
format!("{}.{}", &padded[..split], frac)
188242
}
189243
}
190244

canister/src/dashboard/tests.rs

Lines changed: 73 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use super::{DashboardTemplate, bar_width_percent, saturating_to_u128};
1+
use super::{DashboardTemplate, bar_width_percent, format_scaled, saturating_to_u128};
22
use crate::order::{
33
BasisPoint, FeeRates, OrderBookId, OrderId, PendingOrder, Price, Quantity, Side, TimeInForce,
44
TradingPair,
@@ -103,20 +103,13 @@ fn should_render_per_pair_metadata() {
103103

104104
let dom = render(&state, 0);
105105
let dl_text = text(&dom, "section.pair dl");
106-
let best_bid = 100 * PRICE_SCALE;
107-
let best_ask = 110 * PRICE_SCALE;
108-
assert!(dl_text.contains(&format!("{}", TICK_SIZE.get())));
109-
assert!(dl_text.contains(&format!("{}", LOT_SIZE.get())));
110-
// Pair each <dt> label with its <dd> value so the assertion checks the
111-
// value wiring (and maker/taker are not swapped) without depending on
112-
// whitespace between the tags.
113106
let dts = column(&dom, "section.pair dl dt");
114107
let dds = column(&dom, "section.pair dl dd");
115108
let value_for = |label: &str| {
116109
dts.iter()
117110
.zip(&dds)
118111
.find(|(dt, _)| dt.trim() == label)
119-
.map(|(_, dd)| dd.trim().to_string())
112+
.map(|(_, dd)| dd.split_whitespace().collect::<Vec<_>>().join(" "))
120113
};
121114
let expected_maker = format!("{MAKER_FEE_BPS} bps");
122115
let expected_taker = format!("{TAKER_FEE_BPS} bps");
@@ -128,18 +121,29 @@ fn should_render_per_pair_metadata() {
128121
value_for("Taker fee").as_deref(),
129122
Some(expected_taker.as_str())
130123
);
131-
assert!(
132-
dl_text.contains(&best_bid.to_string()),
133-
"best bid {best_bid} in: {dl_text}"
124+
assert_eq!(
125+
value_for("Tick size").as_deref(),
126+
Some("0.000001 ckBTC/ICP (100/10^8)")
134127
);
135-
assert!(
136-
dl_text.contains(&best_ask.to_string()),
137-
"best ask {best_ask} in: {dl_text}"
128+
assert_eq!(
129+
value_for("Lot size").as_deref(),
130+
Some("0.01 ICP (1_000_000)")
131+
);
132+
assert_eq!(
133+
value_for("Best bid").as_deref(),
134+
Some("0.01 ICP @ 100 ckBTC/ICP (1_000_000 @ 10_000_000_000/10^8)")
135+
);
136+
assert_eq!(
137+
value_for("Best ask").as_deref(),
138+
Some("0.01 ICP @ 110 ckBTC/ICP (1_000_000 @ 11_000_000_000/10^8)")
139+
);
140+
assert_eq!(
141+
value_for("Spread").as_deref(),
142+
Some("10 ckBTC/ICP (1_000_000_000/10^8)")
138143
);
139144
assert!(
140-
dl_text.contains(&(best_ask - best_bid).to_string()),
141-
"spread {} in: {dl_text}",
142-
best_ask - best_bid
145+
dl_text.contains("0.000001"),
146+
"formatted tick size in: {dl_text}"
143147
);
144148
}
145149

@@ -153,12 +157,12 @@ fn should_render_depth_chart_for_resting_orders() {
153157

154158
let dom = render(&state, 0);
155159

156-
let bid_prices = column(&dom, "table.depth-bids td.price");
157-
assert_eq!(bid_prices, vec![(100 * PRICE_SCALE).to_string()]);
158-
let ask_prices = column(&dom, "table.depth-asks td.price");
159-
assert_eq!(ask_prices, vec![(110 * PRICE_SCALE).to_string()]);
160-
let bid_qtys = column(&dom, "table.depth-bids tbody tr td:nth-child(2)");
161-
assert_eq!(bid_qtys, vec![candid::Nat::from(lot(1)).to_string()]);
160+
let bid_prices = cells(&dom, "table.depth-bids td.price");
161+
assert_eq!(bid_prices, vec!["100 (10_000_000_000/10^8)"]);
162+
let ask_prices = cells(&dom, "table.depth-asks td.price");
163+
assert_eq!(ask_prices, vec!["110 (11_000_000_000/10^8)"]);
164+
let bid_qtys = cells(&dom, "table.depth-bids tbody tr td:nth-child(2)");
165+
assert_eq!(bid_qtys, vec!["0.01 (1_000_000)"]);
162166

163167
assert_eq!(bar_widths(&dom), vec!["width: 100%", "width: 100%"]);
164168
}
@@ -210,6 +214,39 @@ fn should_saturate_quantity_to_u128() {
210214
assert_eq!(saturating_to_u128(&Quantity::MAX), u128::MAX);
211215
}
212216

217+
#[test]
218+
fn should_format_scaled_with_zero_decimals_as_passthrough() {
219+
assert_eq!(format_scaled("12345", 0), "12345");
220+
}
221+
222+
#[test]
223+
fn should_format_scaled_sub_one_with_leading_zero() {
224+
assert_eq!(format_scaled("1000000", 9), "0.001");
225+
}
226+
227+
#[test]
228+
fn should_format_scaled_trimming_trailing_zeros() {
229+
assert_eq!(format_scaled("1000000000000000000", 18), "1");
230+
}
231+
232+
#[test]
233+
fn should_format_scaled_exact_mid_value() {
234+
assert_eq!(format_scaled("50000000000000000", 18), "0.05");
235+
}
236+
237+
#[test]
238+
fn should_format_scaled_u256_quantity_without_precision_loss() {
239+
let raw = Quantity::new(1, 0).to_nat().0.to_string();
240+
assert_eq!(
241+
raw, "340282366920938463463374607431768211456",
242+
"Quantity::new(1, 0) is 2^128"
243+
);
244+
assert_eq!(
245+
format_scaled(&raw, 18),
246+
"340282366920938463463.374607431768211456"
247+
);
248+
}
249+
213250
fn fresh_state() -> State<VectorMemory, VectorMemory> {
214251
test_fixtures::state()
215252
}
@@ -303,6 +340,18 @@ fn column(dom: &Html, selector: &str) -> Vec<String> {
303340
.collect()
304341
}
305342

343+
fn cells(dom: &Html, selector: &str) -> Vec<String> {
344+
dom.select(&sel(selector))
345+
.map(|e| {
346+
e.text()
347+
.collect::<String>()
348+
.split_whitespace()
349+
.collect::<Vec<_>>()
350+
.join(" ")
351+
})
352+
.collect()
353+
}
354+
306355
fn bar_widths(dom: &Html) -> Vec<String> {
307356
dom.select(&sel("td.bar div"))
308357
.map(|d| d.value().attr("style").unwrap_or("").to_string())

0 commit comments

Comments
 (0)