Merge branch 'priceChange' into 'master'
More flexible pricechange calc for less than 24h, 7d, of trading since creation See merge request riftenlabs/riftenlabs-indexer!99
This commit is contained in:
commit
feeb37081c
8 changed files with 1037 additions and 71 deletions
|
|
@ -20,6 +20,7 @@ pub mod mempool;
|
|||
pub mod ohlcv;
|
||||
pub mod pool;
|
||||
pub mod poolvisitor;
|
||||
pub mod priceseries;
|
||||
pub mod tokenlist;
|
||||
pub mod tokentoken;
|
||||
pub mod tx;
|
||||
|
|
|
|||
411
src/db/cauldron/priceseries.rs
Normal file
411
src/db/cauldron/priceseries.rs
Normal file
|
|
@ -0,0 +1,411 @@
|
|||
// Copyright (C) 2025-2026 Whiterun LLC
|
||||
//
|
||||
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
|
||||
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
|
||||
|
||||
//! Cached 7-day price curve for the token list.
|
||||
//!
|
||||
//! The series samples the *aggregate pool price* — summed reserves over every live
|
||||
//! pool of the token — which is the same measurement `price_now`, `price_7d` and
|
||||
//! therefore `change_7d_bp` are built from. Serving it with the list lets a client
|
||||
//! draw a 7d curve whose ends are the very numbers it prints as percentages,
|
||||
//! without a request per token.
|
||||
//!
|
||||
//! Candlesticks cannot fill that role: they price *executed trades* (a per-tx
|
||||
//! VWAP, so they carry slippage and, for a token with many pools, only the pool
|
||||
//! that happened to trade). On a quiet token their last close can sit percent
|
||||
//! away from the pool price, which makes a curve drawn from them contradict the
|
||||
//! percentage beside it.
|
||||
//!
|
||||
//! Cost per cycle is one snapshot query per token plus two window-wide queries;
|
||||
//! everything inside the window is replayed in memory from `pool_history_entry`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::Result;
|
||||
use bitcoincash::TokenID;
|
||||
use riftenlabs_defi::chainutil::OutPointHash;
|
||||
use rust_decimal::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::db::blob::blob_to_display_hex;
|
||||
use crate::db::cauldron::poolvisitor::{
|
||||
db_visit_pool_entries, OptionalFields, OptionalPoolFields, PoolFilters, PoolVisitor,
|
||||
};
|
||||
use crate::db::cauldron::tokenlist::token_utils::{dec_round, dec_to_f64_bounded};
|
||||
|
||||
/// Points in a series: 7 days sampled every 6h, both ends included. The 6h step
|
||||
/// puts the 24h boundary exactly on index 24, so the 1d and 7d moves can be read
|
||||
/// straight off the array.
|
||||
pub(crate) const SERIES_POINTS: usize = 29;
|
||||
|
||||
/// Decimals kept per ratio. Five is ~1e-5 relative precision — far finer than a
|
||||
/// hundred-pixel curve can show, and it keeps the array near 200 bytes.
|
||||
const RATIO_SCALE: u32 = 5;
|
||||
|
||||
/// Reserves of one pool: (sats, token base units).
|
||||
type Reserves = (u64, u64);
|
||||
|
||||
/// A pool state change inside the window.
|
||||
///
|
||||
/// `reserves == None` marks a withdrawal, which has no `pool_history_entry` row
|
||||
/// of its own — it only sets `pool.withdrawn_in_utxo` — so it has to be loaded
|
||||
/// separately or the pool's reserves would linger in the sum forever.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PoolEvent {
|
||||
ts: i64,
|
||||
sequence: i64,
|
||||
pool_id: String,
|
||||
reserves: Option<Reserves>,
|
||||
}
|
||||
|
||||
/// Every pool state change after a cutoff, grouped by token hex.
|
||||
pub(crate) struct WindowEvents {
|
||||
by_token: HashMap<String, Vec<PoolEvent>>,
|
||||
}
|
||||
|
||||
impl WindowEvents {
|
||||
pub(crate) fn for_token(&self, token_id: &str) -> &[PoolEvent] {
|
||||
self.by_token
|
||||
.get(token_id)
|
||||
.map(|v| v.as_slice())
|
||||
.unwrap_or(&[])
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ReservesVisitor {
|
||||
reserves: HashMap<String, Reserves>,
|
||||
}
|
||||
|
||||
impl PoolVisitor for ReservesVisitor {
|
||||
fn optional_fields_wanted(&self) -> u64 {
|
||||
OptionalPoolFields::PoolId as u64
|
||||
}
|
||||
|
||||
fn visit(&mut self, sats: u64, tokens: u64, optional: OptionalFields) -> Result<bool> {
|
||||
if let Some(pool_id) = optional.pool_id {
|
||||
self.reserves.insert(pool_id, (sats, tokens));
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-pool reserves for `token_id` as of `ts`.
|
||||
///
|
||||
/// Uses the same liveness rules as `price_at_or_before_2` (each pool's latest
|
||||
/// entry at or before `ts`, pools withdrawn before `ts` excluded), so summing
|
||||
/// this map reproduces that function's price exactly — which is what makes
|
||||
/// entry 0 of the series equal `price_7d`.
|
||||
pub(crate) async fn pool_reserves_at(
|
||||
cauldron_pool: &SqlitePool,
|
||||
ts: i64,
|
||||
token_id: &TokenID,
|
||||
) -> Result<HashMap<String, Reserves>> {
|
||||
let mut visitor = ReservesVisitor::default();
|
||||
db_visit_pool_entries(
|
||||
cauldron_pool,
|
||||
&mut visitor,
|
||||
PoolFilters::new()
|
||||
.lte(ts as u64)
|
||||
.token_id(&token_id.to_string()),
|
||||
)
|
||||
.await?;
|
||||
Ok(visitor.reserves)
|
||||
}
|
||||
|
||||
/// Load every pool state change after `since`, for all tokens at once.
|
||||
///
|
||||
/// One pass over the window beats a query per token: the whole 7d window is a
|
||||
/// few thousand rows, and `idx_phe_token_id_ts` keeps it an index range scan.
|
||||
pub(crate) async fn load_window_events(
|
||||
cauldron_pool: &SqlitePool,
|
||||
since: i64,
|
||||
) -> Result<WindowEvents> {
|
||||
let mut by_token: HashMap<String, Vec<PoolEvent>> = HashMap::new();
|
||||
|
||||
let entries = sqlx::query(
|
||||
r#"
|
||||
SELECT token_id, pool, sats, token_amount, effective_timestamp, sequence
|
||||
FROM pool_history_entry
|
||||
WHERE effective_timestamp > ?1
|
||||
"#,
|
||||
)
|
||||
.bind(since)
|
||||
.fetch_all(cauldron_pool)
|
||||
.await?;
|
||||
|
||||
for row in entries {
|
||||
let token_blob: Vec<u8> = row.get(0);
|
||||
let Ok(token_id) = blob_to_display_hex::<TokenID>(&token_blob) else {
|
||||
continue;
|
||||
};
|
||||
let pool_blob: Vec<u8> = row.get(1);
|
||||
let Ok(pool_id) = blob_to_display_hex::<OutPointHash>(&pool_blob) else {
|
||||
continue;
|
||||
};
|
||||
let sats: i64 = row.get(2);
|
||||
let tokens: i64 = row.get(3);
|
||||
by_token.entry(token_id).or_default().push(PoolEvent {
|
||||
ts: row.get(4),
|
||||
sequence: row.get(5),
|
||||
pool_id,
|
||||
reserves: Some((sats.max(0) as u64, tokens.max(0) as u64)),
|
||||
});
|
||||
}
|
||||
|
||||
let withdrawals = sqlx::query(
|
||||
r#"
|
||||
SELECT p.token_id, p.creation_utxo, t.effective_timestamp
|
||||
FROM pool p
|
||||
JOIN utxo_spending us ON us.spent_utxo_hash = p.withdrawn_in_utxo
|
||||
JOIN tx t ON us.txid = t.txid
|
||||
WHERE t.effective_timestamp > ?1
|
||||
"#,
|
||||
)
|
||||
.bind(since)
|
||||
.fetch_all(cauldron_pool)
|
||||
.await?;
|
||||
|
||||
for row in withdrawals {
|
||||
let token_blob: Vec<u8> = row.get(0);
|
||||
let Ok(token_id) = blob_to_display_hex::<TokenID>(&token_blob) else {
|
||||
continue;
|
||||
};
|
||||
let pool_blob: Vec<u8> = row.get(1);
|
||||
let Ok(pool_id) = blob_to_display_hex::<OutPointHash>(&pool_blob) else {
|
||||
continue;
|
||||
};
|
||||
let withdrawn_ts: i64 = row.get(2);
|
||||
by_token.entry(token_id).or_default().push(PoolEvent {
|
||||
// A pool whose withdrawal lands exactly on the sample timestamp is
|
||||
// still counted there (`db_visit_pool_entries` keeps it while the
|
||||
// withdrawal is `>=` the bound), so it may only drop out strictly
|
||||
// after. Recording the event a second late lets the replay use one
|
||||
// `<=` comparison for both kinds of event.
|
||||
ts: withdrawn_ts.saturating_add(1),
|
||||
// after any entry sharing the timestamp
|
||||
sequence: i64::MAX,
|
||||
pool_id,
|
||||
reserves: None,
|
||||
});
|
||||
}
|
||||
|
||||
for events in by_token.values_mut() {
|
||||
events.sort_by_key(|e| (e.ts, e.sequence));
|
||||
}
|
||||
|
||||
Ok(WindowEvents { by_token })
|
||||
}
|
||||
|
||||
/// Aggregate pool price, per smallest token unit.
|
||||
///
|
||||
/// `None` when the token has no priceable reserves — the same situation in which
|
||||
/// `price_at_or_before_2` reports no price rather than zero.
|
||||
fn aggregate_price(reserves: &HashMap<String, Reserves>) -> Option<Decimal> {
|
||||
let mut sats: u128 = 0;
|
||||
let mut tokens: u128 = 0;
|
||||
for (s, t) in reserves.values() {
|
||||
sats = sats.saturating_add(*s as u128);
|
||||
tokens = tokens.saturating_add(*t as u128);
|
||||
}
|
||||
if sats == 0 || tokens == 0 {
|
||||
return None;
|
||||
}
|
||||
Decimal::from_u128(sats)?.checked_div(Decimal::from_u128(tokens)?)
|
||||
}
|
||||
|
||||
/// Sample the aggregate pool price across `[window_start, now]`.
|
||||
///
|
||||
/// `reserves` is the state at `window_start` per [`pool_reserves_at`], `events`
|
||||
/// that token's changes after it (sorted, as [`load_window_events`] returns
|
||||
/// them). Entry `k` is the price at `window_start + k * span / (SERIES_POINTS-1)`,
|
||||
/// so entry 0 is the window start and the last entry is `now`. `None` marks a
|
||||
/// bucket where the token had no priceable pool yet — a token younger than the
|
||||
/// window simply starts with a run of them.
|
||||
pub(crate) fn build_price_series(
|
||||
mut reserves: HashMap<String, Reserves>,
|
||||
events: &[PoolEvent],
|
||||
window_start: i64,
|
||||
now: i64,
|
||||
) -> Vec<Option<Decimal>> {
|
||||
let span = (now - window_start).max(0);
|
||||
let last = (SERIES_POINTS - 1) as i64;
|
||||
let mut out = Vec::with_capacity(SERIES_POINTS);
|
||||
let mut next = 0usize;
|
||||
|
||||
for k in 0..SERIES_POINTS as i64 {
|
||||
// scaled from the span rather than a fixed step so the final sample lands
|
||||
// exactly on `now` however the window is sized
|
||||
let t = window_start + (span * k) / last;
|
||||
while next < events.len() && events[next].ts <= t {
|
||||
let event = &events[next];
|
||||
match event.reserves {
|
||||
Some(r) => {
|
||||
reserves.insert(event.pool_id.clone(), r);
|
||||
}
|
||||
None => {
|
||||
reserves.remove(&event.pool_id);
|
||||
}
|
||||
}
|
||||
next += 1;
|
||||
}
|
||||
out.push(aggregate_price(&reserves));
|
||||
}
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Serialize a series as a JSON array of ratios to `base`, `null` where unknown.
|
||||
///
|
||||
/// Ratios rather than prices: they are unit-free (no decimals to reconcile),
|
||||
/// they compress to a few bytes each, and a client that wants absolute values
|
||||
/// already has the exact endpoints in `price_7d`/`price_now`. Returns `None` when
|
||||
/// there is nothing to anchor against or no price in the whole window, so the
|
||||
/// column stays NULL instead of holding a meaningless array.
|
||||
pub(crate) fn encode_price_series(series: &[Option<Decimal>], base: Decimal) -> Option<String> {
|
||||
if base <= Decimal::ZERO || series.iter().all(|p| p.is_none()) {
|
||||
return None;
|
||||
}
|
||||
let ratios: Vec<Option<f64>> = series
|
||||
.iter()
|
||||
.map(|p| {
|
||||
p.and_then(|price| price.checked_div(base))
|
||||
.map(|ratio| dec_to_f64_bounded(dec_round(ratio, RATIO_SCALE)))
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string(&ratios).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rust_decimal_macros::dec;
|
||||
|
||||
const DAY: i64 = 86_400;
|
||||
const WEEK: i64 = 7 * DAY;
|
||||
const STEP: i64 = 6 * 3600;
|
||||
|
||||
fn reserves(pairs: &[(&str, u64, u64)]) -> HashMap<String, Reserves> {
|
||||
pairs
|
||||
.iter()
|
||||
.map(|(id, s, t)| (id.to_string(), (*s, *t)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn entry(ts: i64, pool: &str, sats: u64, tokens: u64) -> PoolEvent {
|
||||
PoolEvent {
|
||||
ts,
|
||||
sequence: ts,
|
||||
pool_id: pool.to_string(),
|
||||
reserves: Some((sats, tokens)),
|
||||
}
|
||||
}
|
||||
|
||||
fn withdrawal(ts: i64, pool: &str) -> PoolEvent {
|
||||
PoolEvent {
|
||||
ts: ts + 1,
|
||||
sequence: i64::MAX,
|
||||
pool_id: pool.to_string(),
|
||||
reserves: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_both_ends_of_the_window() {
|
||||
let series = build_price_series(reserves(&[("a", 100, 200)]), &[], 0, WEEK);
|
||||
assert_eq!(series.len(), SERIES_POINTS);
|
||||
// nothing happened, so every sample is the starting price
|
||||
assert!(series.iter().all(|p| *p == Some(dec!(0.5))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_24h_boundary_lands_on_index_24() {
|
||||
// a 6h step over 7 days puts now-24h exactly four samples from the end
|
||||
let series = build_price_series(
|
||||
reserves(&[("a", 100, 100)]),
|
||||
&[entry(WEEK - DAY, "a", 200, 100)],
|
||||
0,
|
||||
WEEK,
|
||||
);
|
||||
assert_eq!(series[23], Some(dec!(1)));
|
||||
assert_eq!(series[24], Some(dec!(2)));
|
||||
assert_eq!(series[SERIES_POINTS - 1], Some(dec!(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replays_trades_onto_the_grid() {
|
||||
let series = build_price_series(
|
||||
reserves(&[("a", 100, 100)]),
|
||||
&[entry(STEP, "a", 150, 100), entry(3 * STEP, "a", 300, 100)],
|
||||
0,
|
||||
WEEK,
|
||||
);
|
||||
assert_eq!(series[0], Some(dec!(1)));
|
||||
assert_eq!(series[1], Some(dec!(1.5)));
|
||||
assert_eq!(series[2], Some(dec!(1.5)));
|
||||
assert_eq!(series[3], Some(dec!(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sums_every_live_pool() {
|
||||
// the aggregate is summed reserves, not an average of per-pool prices
|
||||
let series =
|
||||
build_price_series(reserves(&[("a", 100, 100), ("b", 900, 100)]), &[], 0, WEEK);
|
||||
assert_eq!(series[0], Some(dec!(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_a_withdrawn_pool_only_after_its_timestamp() {
|
||||
// matches db_visit_pool_entries, which still counts a pool whose
|
||||
// withdrawal is exactly at the sample timestamp
|
||||
let series = build_price_series(
|
||||
reserves(&[("a", 100, 100), ("b", 900, 100)]),
|
||||
&[withdrawal(2 * STEP, "b")],
|
||||
0,
|
||||
WEEK,
|
||||
);
|
||||
assert_eq!(series[2], Some(dec!(5)));
|
||||
assert_eq!(series[3], Some(dec!(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pool_created_inside_the_window_starts_the_curve() {
|
||||
// a token younger than the window has no price until its first pool
|
||||
let series = build_price_series(HashMap::new(), &[entry(3 * STEP, "a", 100, 100)], 0, WEEK);
|
||||
assert_eq!(series[2], None);
|
||||
assert_eq!(series[3], Some(dec!(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_price_at_all_stays_null() {
|
||||
let series = build_price_series(HashMap::new(), &[], 0, WEEK);
|
||||
assert!(series.iter().all(|p| p.is_none()));
|
||||
assert_eq!(encode_price_series(&series, dec!(1)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encodes_ratios_to_the_base_price() {
|
||||
let series = vec![Some(dec!(0.5)), None, Some(dec!(1)), Some(dec!(2))];
|
||||
let json = encode_price_series(&series, dec!(1)).unwrap();
|
||||
assert_eq!(json, "[0.5,null,1.0,2.0]");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_needs_a_usable_base() {
|
||||
let series = vec![Some(dec!(1))];
|
||||
assert_eq!(encode_price_series(&series, dec!(0)), None);
|
||||
assert_eq!(encode_price_series(&series, dec!(-1)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_keeps_the_payload_small() {
|
||||
// ~200 bytes per token is the whole point: it replaces a per-row request
|
||||
let series: Vec<Option<Decimal>> = (0..SERIES_POINTS)
|
||||
.map(|i| Some(dec!(1) + Decimal::from(i) / dec!(100000)))
|
||||
.collect();
|
||||
let json = encode_price_series(&series, dec!(1)).unwrap();
|
||||
assert!(json.len() < 300, "series encoded to {} bytes", json.len());
|
||||
}
|
||||
}
|
||||
|
|
@ -212,6 +212,27 @@ CREATE TABLE IF NOT EXISTS cached_token_metrics (
|
|||
add_column_if_missing(pool, "cached_token_metrics", "first_pool_ts", "INTEGER").await?;
|
||||
add_column_if_missing(pool, "cached_token_metrics", "bcmr_json", "TEXT").await?;
|
||||
add_column_if_missing(pool, "cached_token_metrics", "bcmr_well_known_json", "TEXT").await?;
|
||||
// Timestamp each change_* was actually measured from. For a token younger than
|
||||
// the window this is its first pool, not `now - window`, so consumers can label
|
||||
// a since-launch move as such instead of passing it off as a full 24h/7d one.
|
||||
add_column_if_missing(
|
||||
pool,
|
||||
"cached_token_metrics",
|
||||
"change_24h_anchor_ts",
|
||||
"INTEGER",
|
||||
)
|
||||
.await?;
|
||||
add_column_if_missing(
|
||||
pool,
|
||||
"cached_token_metrics",
|
||||
"change_7d_anchor_ts",
|
||||
"INTEGER",
|
||||
)
|
||||
.await?;
|
||||
// 7d aggregate-pool-price curve as a JSON array of ratios to price_now; see
|
||||
// db::cauldron::priceseries. Lets the token list ship a sparkline instead of
|
||||
// every client asking for candlesticks per row.
|
||||
add_column_if_missing(pool, "cached_token_metrics", "price_series_7d", "TEXT").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,8 +34,18 @@ pub struct TokenListItemCached {
|
|||
pub price_7d_usd: Option<f64>,
|
||||
pub change_24h_usd_bp: Option<i64>,
|
||||
pub change_7d_usd_bp: Option<i64>,
|
||||
/// Timestamp the matching `change_*` was measured from. Equals `now - window`
|
||||
/// for a token with enough history; for a younger one it is the token's first
|
||||
/// pool, i.e. the change is a since-launch move over a shorter period.
|
||||
pub change_24h_anchor_ts: Option<i64>,
|
||||
pub change_7d_anchor_ts: Option<i64>,
|
||||
pub apy_30d_bp: Option<i64>,
|
||||
pub first_pool_ts: Option<i64>,
|
||||
/// 7d aggregate-pool-price curve: 29 samples every 6h ending at now, each a
|
||||
/// ratio to `price_now`, `null` before the token had a pool. Same measurement
|
||||
/// as `change_7d_bp`, so a client can draw a sparkline that agrees with the
|
||||
/// percentages without fetching candlesticks per token.
|
||||
pub price_series_7d: Option<Vec<Option<f64>>>,
|
||||
}
|
||||
|
||||
const TOKEN_METRICS_COLUMNS: &str = r#"
|
||||
|
|
@ -56,11 +66,14 @@ const TOKEN_METRICS_COLUMNS: &str = r#"
|
|||
price_7d_usd,
|
||||
change_24h_usd_bp,
|
||||
change_7d_usd_bp,
|
||||
change_24h_anchor_ts,
|
||||
change_7d_anchor_ts,
|
||||
apy_30d_bp,
|
||||
first_pool_ts,
|
||||
score_rank,
|
||||
bcmr_json,
|
||||
bcmr_well_known_json
|
||||
bcmr_well_known_json,
|
||||
price_series_7d
|
||||
"#;
|
||||
|
||||
fn parse_bcmr_columns(
|
||||
|
|
@ -93,12 +106,17 @@ fn parse_row(row: &sqlx::sqlite::SqliteRow) -> Result<TokenListItemCached> {
|
|||
let price_7d_usd: Option<f64> = row.get("price_7d_usd");
|
||||
let change_24h_usd_bp: Option<i64> = row.get("change_24h_usd_bp");
|
||||
let change_7d_usd_bp: Option<i64> = row.get("change_7d_usd_bp");
|
||||
let change_24h_anchor_ts: Option<i64> = row.get("change_24h_anchor_ts");
|
||||
let change_7d_anchor_ts: Option<i64> = row.get("change_7d_anchor_ts");
|
||||
let apy_30d_bp: Option<i64> = row.get("apy_30d_bp");
|
||||
// 0 is the "not backfilled yet" sentinel — treat it as unknown.
|
||||
let first_pool_ts: Option<i64> = row
|
||||
.get::<Option<i64>, _>("first_pool_ts")
|
||||
.filter(|&ts| ts != 0);
|
||||
let score_rank: i64 = row.get("score_rank");
|
||||
let price_series_7d: Option<Vec<Option<f64>>> = row
|
||||
.get::<Option<String>, _>("price_series_7d")
|
||||
.and_then(|s| serde_json::from_str(&s).ok());
|
||||
let bcmr_json_str: Option<String> = row.get("bcmr_json");
|
||||
let bcmr_wk_json_str: Option<String> = row.get("bcmr_well_known_json");
|
||||
let (bcmr, bcmr_well_known) = parse_bcmr_columns(bcmr_json_str, bcmr_wk_json_str);
|
||||
|
|
@ -125,8 +143,11 @@ fn parse_row(row: &sqlx::sqlite::SqliteRow) -> Result<TokenListItemCached> {
|
|||
price_7d_usd,
|
||||
change_24h_usd_bp,
|
||||
change_7d_usd_bp,
|
||||
change_24h_anchor_ts,
|
||||
change_7d_anchor_ts,
|
||||
apy_30d_bp,
|
||||
first_pool_ts,
|
||||
price_series_7d,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,16 +10,18 @@ use sqlx::{Row, SqlitePool};
|
|||
use crate::db::bcmr::{get_token_bcmr, get_well_known_bcmr};
|
||||
use crate::db::blob::{blob_to_display_hex, display_hex_to_blob};
|
||||
use crate::db::cauldron::poolvisitor::{db_visit_pool_entries, PoolFilters};
|
||||
use crate::db::cauldron::priceseries::{
|
||||
build_price_series, encode_price_series, load_window_events, pool_reserves_at,
|
||||
};
|
||||
use crate::db::cauldron::tokenlist::db_utils::{
|
||||
cache_first_pool_ts_if_empty, db_first_pool_creation_row,
|
||||
};
|
||||
use crate::db::cauldron::tokenlist::token_utils::{
|
||||
apy_30d_bp_for_token, compute_score, dec_round, dec_to_f64_bounded, overflow_f64_fallback,
|
||||
pct_change_bp_dec, pow10_dec, price_from_tvl, resolve_decimals, resolve_display_labels,
|
||||
usd_per_bch_at_or_before, SATS_PER_BCH,
|
||||
pct_change_bp_dec, pow10_dec, price_anchor_for_window, price_from_tvl, resolve_decimals,
|
||||
resolve_display_labels, usd_per_bch_at_or_before, SATS_PER_BCH,
|
||||
};
|
||||
use crate::db::DB;
|
||||
use crate::rpc::price::price_at_or_before_2;
|
||||
use crate::rpc::tvl::TvlByTokenVisitor;
|
||||
use crate::signal::shutdown_requested;
|
||||
use crate::timeutil::time_now;
|
||||
|
|
@ -241,6 +243,10 @@ pub async fn recompute_score_ranks(pool: &SqlitePool) -> anyhow::Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// One row of the metrics read phase: (token_id blob, display hex, tvl_sats,
|
||||
/// tvl_tokens, first_pool_ts).
|
||||
type TokenReadRow = (Vec<u8>, String, u64, u64, Option<i64>);
|
||||
|
||||
/// Holds computed metrics for a single token, ready to be written to DB.
|
||||
struct TokenMetricsUpdate {
|
||||
token_blob: Vec<u8>,
|
||||
|
|
@ -255,6 +261,10 @@ struct TokenMetricsUpdate {
|
|||
price_7d_usd: Option<f64>,
|
||||
change_24h_usd_bp: Option<i64>,
|
||||
change_7d_usd_bp: Option<i64>,
|
||||
change_24h_anchor_ts: Option<i64>,
|
||||
change_7d_anchor_ts: Option<i64>,
|
||||
/// 7d curve as a JSON array of ratios to price_now, see `priceseries`.
|
||||
price_series_7d: Option<String>,
|
||||
display_name: Option<String>,
|
||||
display_symbol: Option<String>,
|
||||
bcmr_json: Option<String>,
|
||||
|
|
@ -286,6 +296,9 @@ async fn flush_metrics_update_batch(
|
|||
display_symbol = ?14,
|
||||
bcmr_json = ?15,
|
||||
bcmr_well_known_json = ?16,
|
||||
change_24h_anchor_ts = ?17,
|
||||
change_7d_anchor_ts = ?18,
|
||||
price_series_7d = ?19,
|
||||
updated_at = CAST(strftime('%s','now') AS INTEGER)
|
||||
WHERE token_id = ?1
|
||||
"#,
|
||||
|
|
@ -306,6 +319,9 @@ async fn flush_metrics_update_batch(
|
|||
.bind(&u.display_symbol)
|
||||
.bind(&u.bcmr_json)
|
||||
.bind(&u.bcmr_well_known_json)
|
||||
.bind(u.change_24h_anchor_ts)
|
||||
.bind(u.change_7d_anchor_ts)
|
||||
.bind(&u.price_series_7d)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
|
@ -313,6 +329,27 @@ async fn flush_metrics_update_batch(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// USD-per-sat at `ts`, memoized. Young tokens anchor their change windows at their
|
||||
/// own launch time, so without the cache the oracle is re-queried once per token.
|
||||
/// A missing oracle point yields `ZERO`, the "unknown rate" sentinel callers already
|
||||
/// test for before writing USD fields.
|
||||
async fn usd_per_sat_at(
|
||||
oracle_pool: &SqlitePool,
|
||||
cache: &mut HashMap<i64, Decimal>,
|
||||
ts: i64,
|
||||
) -> Decimal {
|
||||
if let Some(v) = cache.get(&ts) {
|
||||
return *v;
|
||||
}
|
||||
let sats_per_bch = Decimal::from_i64(SATS_PER_BCH).unwrap();
|
||||
let rate = usd_per_bch_at_or_before(oracle_pool, ts)
|
||||
.await
|
||||
.map(|usd_per_bch| usd_per_bch / sats_per_bch)
|
||||
.unwrap_or(Decimal::ZERO);
|
||||
cache.insert(ts, rate);
|
||||
rate
|
||||
}
|
||||
|
||||
// ---------- 5 min: price changes (24h/7d), score, volume + ranking ----------
|
||||
pub async fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<()> {
|
||||
let now = time_now();
|
||||
|
|
@ -352,14 +389,20 @@ pub async fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<
|
|||
let usd_per_sat_24h = usd_per_bch_at_or_before(&db.oracle_r, ts_24h).await? / sats_per_bch;
|
||||
let usd_per_sat_7d = usd_per_bch_at_or_before(&db.oracle_r, ts_7d).await? / sats_per_bch;
|
||||
|
||||
// Every pool state change in the 7d window, for all tokens in one pass — the
|
||||
// per-token snapshots below only reach the window start, so the series needs
|
||||
// these to walk forward from it.
|
||||
let window_events = load_window_events(&db.cauldron_r, ts_7d).await?;
|
||||
|
||||
// ========== READ PHASE: collect all token data ==========
|
||||
let token_rows = sqlx::query(
|
||||
"SELECT token_id, tvl_sats, tvl_tokens FROM cached_token_metrics WHERE tvl_sats > 0",
|
||||
"SELECT token_id, tvl_sats, tvl_tokens, first_pool_ts
|
||||
FROM cached_token_metrics WHERE tvl_sats > 0",
|
||||
)
|
||||
.fetch_all(&db.cauldron_r)
|
||||
.await?;
|
||||
|
||||
let mut tokens_data: Vec<(Vec<u8>, String, u64, u64)> = Vec::new();
|
||||
let mut tokens_data: Vec<TokenReadRow> = Vec::new();
|
||||
for row in token_rows {
|
||||
let token_blob: Vec<u8> = row.get(0);
|
||||
let token_id = match blob_to_display_hex::<TokenID>(&token_blob) {
|
||||
|
|
@ -368,19 +411,24 @@ pub async fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<
|
|||
};
|
||||
let tvl_sats: i64 = row.get(1);
|
||||
let tvl_tokens: i64 = row.get(2);
|
||||
// 0 is the "not backfilled yet" sentinel — treat it as unknown.
|
||||
let first_pool_ts: Option<i64> = row.get::<Option<i64>, _>(3).filter(|&ts| ts != 0);
|
||||
tokens_data.push((
|
||||
token_blob,
|
||||
token_id,
|
||||
tvl_sats.max(0) as u64,
|
||||
tvl_tokens.max(0) as u64,
|
||||
first_pool_ts,
|
||||
));
|
||||
}
|
||||
|
||||
// ========== COMPUTE PHASE: compute all updates ==========
|
||||
let mut updates: Vec<TokenMetricsUpdate> = Vec::with_capacity(tokens_data.len());
|
||||
let mut dec_cache: HashMap<String, u32> = HashMap::new();
|
||||
let mut usd_rate_cache: HashMap<i64, Decimal> =
|
||||
HashMap::from([(ts_24h, usd_per_sat_24h), (ts_7d, usd_per_sat_7d)]);
|
||||
|
||||
for (token_blob, token_id, tvl_sats, tvl_tokens) in tokens_data {
|
||||
for (token_blob, token_id, tvl_sats, tvl_tokens, first_pool_ts) in tokens_data {
|
||||
let decimals_u32 = if let Some(d) = dec_cache.get(&token_id) {
|
||||
*d
|
||||
} else {
|
||||
|
|
@ -445,6 +493,9 @@ pub async fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<
|
|||
price_7d_usd: None,
|
||||
change_24h_usd_bp: None,
|
||||
change_7d_usd_bp: None,
|
||||
change_24h_anchor_ts: None,
|
||||
change_7d_anchor_ts: None,
|
||||
price_series_7d: None,
|
||||
display_name,
|
||||
display_symbol,
|
||||
bcmr_json,
|
||||
|
|
@ -503,6 +554,9 @@ pub async fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<
|
|||
price_7d_usd: None,
|
||||
change_24h_usd_bp: None,
|
||||
change_7d_usd_bp: None,
|
||||
change_24h_anchor_ts: None,
|
||||
change_7d_anchor_ts: None,
|
||||
price_series_7d: None,
|
||||
display_name,
|
||||
display_symbol,
|
||||
bcmr_json,
|
||||
|
|
@ -512,55 +566,86 @@ pub async fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<
|
|||
}
|
||||
};
|
||||
|
||||
// Historical anchors. For a token younger than the window these fall back to
|
||||
// its first pool, so a new token reports a since-launch move instead of NULL.
|
||||
let anchor_24h = price_anchor_for_window(&db.cauldron_r, ts_24h, first_pool_ts, &tid).await;
|
||||
let anchor_7d = price_anchor_for_window(&db.cauldron_r, ts_7d, first_pool_ts, &tid).await;
|
||||
// The anchor reports the pool entry the price was read from, which on a token
|
||||
// that stopped trading can be years before the window. The change still spans
|
||||
// the whole window — the price simply held — so clamp to the window start.
|
||||
// That is both the period consumers should be told about and the BCH/USD rate
|
||||
// the USD side must convert at: anchoring to a years-old entry priced the USD
|
||||
// side at a years-old rate, or at none at all once the entry predated the
|
||||
// oracle history, turning a correct 0.00% in BCH into N/A in USD.
|
||||
let anchor_24h_ts = anchor_24h.as_ref().map(|a| a.ts.max(ts_24h));
|
||||
let anchor_7d_ts = anchor_7d.as_ref().map(|a| a.ts.max(ts_7d));
|
||||
|
||||
// 7d curve, sampled from the same aggregate pool price as the changes
|
||||
// above so a client can draw it without a request per token. Always the
|
||||
// nominal window, never the launch-time anchor: the grid has to mean the
|
||||
// same thing on every row.
|
||||
let price_series_7d = match pool_reserves_at(&db.cauldron_r, ts_7d, &tid).await {
|
||||
Ok(reserves) => {
|
||||
let series =
|
||||
build_price_series(reserves, window_events.for_token(&token_id), ts_7d, now);
|
||||
encode_price_series(&series, price_now_dec)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("price series unavailable for {token_id}: {e:?}");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
// Convert at the rate of the snapshot we actually read, not of the nominal
|
||||
// window start — otherwise a launch-time price gets a 7-days-ago BCH/USD rate.
|
||||
let usd_per_sat_at_24h = match anchor_24h_ts {
|
||||
Some(ts) => usd_per_sat_at(&db.oracle_r, &mut usd_rate_cache, ts).await,
|
||||
None => Decimal::ZERO,
|
||||
};
|
||||
let usd_per_sat_at_7d = match anchor_7d_ts {
|
||||
Some(ts) => usd_per_sat_at(&db.oracle_r, &mut usd_rate_cache, ts).await,
|
||||
None => Decimal::ZERO,
|
||||
};
|
||||
|
||||
// --------- FAST PATH (no trades in 30d) ----------
|
||||
// Only gate by *existence* of a historical point; if none → keep NULLs
|
||||
// No trades means the token price is unchanged; only gate on the *existence*
|
||||
// of an anchor, and if there is none → keep NULLs.
|
||||
if vol == 0 {
|
||||
let p24_exists = matches!(
|
||||
price_at_or_before_2(&db.cauldron_r, ts_24h, &tid).await,
|
||||
Ok((_ts, _))
|
||||
);
|
||||
let p7d_exists = matches!(
|
||||
price_at_or_before_2(&db.cauldron_r, ts_7d, &tid).await,
|
||||
Ok((_ts, _))
|
||||
);
|
||||
let price_24h_human_f = anchor_24h
|
||||
.as_ref()
|
||||
.map(|_| dec_to_f64_bounded(dec_round(price_now_human_dec, 12)));
|
||||
let price_7d_human_f = anchor_7d
|
||||
.as_ref()
|
||||
.map(|_| dec_to_f64_bounded(dec_round(price_now_human_dec, 12)));
|
||||
|
||||
let price_24h_human_f = if p24_exists {
|
||||
Some(dec_to_f64_bounded(dec_round(price_now_human_dec, 12)))
|
||||
let d24_bp_opt = anchor_24h.as_ref().map(|_| 0i64);
|
||||
let d7d_bp_opt = anchor_7d.as_ref().map(|_| 0i64);
|
||||
|
||||
// Keep the unrounded Decimals: `dec_round(_, 12)` is twelve *decimal
|
||||
// places*, not significant digits, so a token worth ~1e-12 USD per unit
|
||||
// survives it with a single digit. Taking the delta from the rounded
|
||||
// value made a flat token report that rounding error as a price move
|
||||
// (one at 9e-12 came out at +0.90%, another at +18.75%).
|
||||
let price_24h_usd_dec = if price_24h_human_f.is_some() && !usd_per_sat_at_24h.is_zero()
|
||||
{
|
||||
price_now_human_dec.checked_mul(usd_per_sat_at_24h)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let price_7d_human_f = if p7d_exists {
|
||||
Some(dec_to_f64_bounded(dec_round(price_now_human_dec, 12)))
|
||||
let price_7d_usd_dec = if price_7d_human_f.is_some() && !usd_per_sat_at_7d.is_zero() {
|
||||
price_now_human_dec.checked_mul(usd_per_sat_at_7d)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let d24_bp_opt = if p24_exists { Some(0) } else { None };
|
||||
let d7d_bp_opt = if p7d_exists { Some(0) } else { None };
|
||||
let price_24h_usd_f = price_24h_usd_dec.map(|d| dec_to_f64_bounded(dec_round(d, 12)));
|
||||
let price_7d_usd_f = price_7d_usd_dec.map(|d| dec_to_f64_bounded(dec_round(d, 12)));
|
||||
|
||||
let price_24h_usd_f = if p24_exists && !usd_per_sat_24h.is_zero() {
|
||||
price_now_human_dec
|
||||
.checked_mul(usd_per_sat_24h)
|
||||
.map(|d| dec_to_f64_bounded(dec_round(d, 12)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let price_7d_usd_f = if p7d_exists && !usd_per_sat_7d.is_zero() {
|
||||
price_now_human_dec
|
||||
.checked_mul(usd_per_sat_7d)
|
||||
.map(|d| dec_to_f64_bounded(dec_round(d, 12)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let old24_u_dec_opt = price_24h_usd_f.and_then(Decimal::from_f64);
|
||||
let old7d_u_dec_opt = price_7d_usd_f.and_then(Decimal::from_f64);
|
||||
|
||||
let d24_usd_bp_opt = match (price_now_usd_dec_opt, old24_u_dec_opt) {
|
||||
let d24_usd_bp_opt = match (price_now_usd_dec_opt, price_24h_usd_dec) {
|
||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||
_ => None,
|
||||
};
|
||||
let d7d_usd_bp_opt = match (price_now_usd_dec_opt, old7d_u_dec_opt) {
|
||||
let d7d_usd_bp_opt = match (price_now_usd_dec_opt, price_7d_usd_dec) {
|
||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||
_ => None,
|
||||
};
|
||||
|
|
@ -578,6 +663,9 @@ pub async fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<
|
|||
price_7d_usd: price_7d_usd_f,
|
||||
change_24h_usd_bp: d24_usd_bp_opt,
|
||||
change_7d_usd_bp: d7d_usd_bp_opt,
|
||||
change_24h_anchor_ts: anchor_24h_ts,
|
||||
change_7d_anchor_ts: anchor_7d_ts,
|
||||
price_series_7d,
|
||||
display_name,
|
||||
display_symbol,
|
||||
bcmr_json: bcmr_json.clone(),
|
||||
|
|
@ -587,14 +675,8 @@ pub async fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<
|
|||
}
|
||||
|
||||
// --------- FULL PATH (vol > 0) ----------
|
||||
let p24_dec_opt = price_at_or_before_2(&db.cauldron_r, ts_24h, &tid)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|(_, p)| Decimal::from_f64(p));
|
||||
let p7d_dec_opt = price_at_or_before_2(&db.cauldron_r, ts_7d, &tid)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|(_, p)| Decimal::from_f64(p));
|
||||
let p24_dec_opt = anchor_24h.map(|a| a.price);
|
||||
let p7d_dec_opt = anchor_7d.map(|a| a.price);
|
||||
|
||||
// scale to "human" with overflow guard (nullable)
|
||||
let price_24h_human_opt = p24_dec_opt.and_then(|p| p.checked_mul(factor));
|
||||
|
|
@ -605,12 +687,12 @@ pub async fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<
|
|||
let d7d_bp_opt = p7d_dec_opt.map(|p| pct_change_bp_dec(price_now_dec, p));
|
||||
|
||||
// USD historicals (nullable if oracle missing or overflow)
|
||||
let price_24h_usd_opt = match (price_24h_human_opt, !usd_per_sat_24h.is_zero()) {
|
||||
(Some(h), true) => h.checked_mul(usd_per_sat_24h),
|
||||
let price_24h_usd_opt = match (price_24h_human_opt, !usd_per_sat_at_24h.is_zero()) {
|
||||
(Some(h), true) => h.checked_mul(usd_per_sat_at_24h),
|
||||
_ => None,
|
||||
};
|
||||
let price_7d_usd_opt = match (price_7d_human_opt, !usd_per_sat_7d.is_zero()) {
|
||||
(Some(h), true) => h.checked_mul(usd_per_sat_7d),
|
||||
let price_7d_usd_opt = match (price_7d_human_opt, !usd_per_sat_at_7d.is_zero()) {
|
||||
(Some(h), true) => h.checked_mul(usd_per_sat_at_7d),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
|
|
@ -618,9 +700,9 @@ pub async fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<
|
|||
p24_dec_opt.map(|p24| to_human_f64_with_fallback(p24, decimals_u32));
|
||||
let price_7d_human_f = p7d_dec_opt.map(|p7d| to_human_f64_with_fallback(p7d, decimals_u32));
|
||||
let price_24h_usd_f =
|
||||
to_usd_f64_with_fallback(price_24h_human_opt, usd_per_sat_24h, decimals_u32);
|
||||
to_usd_f64_with_fallback(price_24h_human_opt, usd_per_sat_at_24h, decimals_u32);
|
||||
let price_7d_usd_f =
|
||||
to_usd_f64_with_fallback(price_7d_human_opt, usd_per_sat_7d, decimals_u32);
|
||||
to_usd_f64_with_fallback(price_7d_human_opt, usd_per_sat_at_7d, decimals_u32);
|
||||
|
||||
let d24_usd_bp_opt = match (price_now_usd_dec_opt, price_24h_usd_opt) {
|
||||
(Some(now_u), Some(old_u)) => Some(pct_change_bp_dec(now_u, old_u)),
|
||||
|
|
@ -644,6 +726,9 @@ pub async fn update_changes_score_volume_and_ranking(db: &DB) -> anyhow::Result<
|
|||
price_7d_usd: price_7d_usd_f,
|
||||
change_24h_usd_bp: d24_usd_bp_opt,
|
||||
change_7d_usd_bp: d7d_usd_bp_opt,
|
||||
change_24h_anchor_ts: anchor_24h_ts,
|
||||
change_7d_anchor_ts: anchor_7d_ts,
|
||||
price_series_7d,
|
||||
display_name,
|
||||
display_symbol,
|
||||
bcmr_json,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ mod tests {
|
|||
};
|
||||
use crate::db::cauldron::tokenlist::token_utils::{
|
||||
apy_30d_bp_for_token, compute_score, overflow_f64_fallback, pct_change_bp_dec, pow10_dec,
|
||||
price_from_tvl, resolve_decimals, resolve_display_labels,
|
||||
price_anchor_for_window, price_from_tvl, resolve_decimals, resolve_display_labels,
|
||||
};
|
||||
use crate::db::cauldron::tx::{insert_block_tx, insert_mempool_tx};
|
||||
use crate::db::cauldron::utxo_funding::insert_utxo_funding;
|
||||
|
|
@ -587,7 +587,7 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn young_token_under_24h_is_null() {
|
||||
fn window_with_no_anchor_at_all_is_null() {
|
||||
let now = dec!(0.00000123); // in sats/base (arbitrary)
|
||||
let factor = dec!(100); // pretend 2 decimals
|
||||
let usd_now = dec!(0.000002); // usd per sat now
|
||||
|
|
@ -731,36 +731,37 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_updater_keeps_deltas_null_for_young_token() {
|
||||
async fn core_updater_anchors_young_token_at_first_pool_instead_of_null() {
|
||||
let mock = mock_db_pool(|pool| async move { setup_basic_schemas(pool).await }).await;
|
||||
let pool = &mock.cauldron_w;
|
||||
|
||||
// Seed a YOUNG token with *volume* so we do the full path, but with no 24h anchor.
|
||||
// Make both events within the last hour.
|
||||
// A token launched 17h ago: younger than BOTH windows, so neither the 24h nor
|
||||
// the 7d probe finds a snapshot at the window start. It used to report N/A.
|
||||
let now = time_now();
|
||||
let token = TokenID::from_byte_array([0xCC; 32]);
|
||||
let t0 = now - 3600; // 1h ago
|
||||
let t0 = now - 17 * 3600;
|
||||
let t1 = now - 600; // 10m ago
|
||||
// launch price = 1_000/500 = 2 sats per base unit
|
||||
seed_minimal_token_history(pool, token, t0, t1, 1_000, 500, 2_000, 1_000).await;
|
||||
|
||||
// Ensure there is a cached row picked up by the updater (tvl_sats>0 is enough).
|
||||
// price_now = 3_000/1_000 = 3 sats → +50% since launch = +5000 bp
|
||||
sqlx::query(
|
||||
"INSERT INTO cached_token_metrics(token_id, trade_volume, tvl_sats, tvl_tokens, score,
|
||||
price_now, price_now_usd, updated_at)
|
||||
VALUES (?, 0, 1, 1, 0, 1.0, 0.5, strftime('%s','now'))",
|
||||
VALUES (?, 0, 3000, 1000, 0, 3.0, 1.5, strftime('%s','now'))",
|
||||
)
|
||||
.bind(token.to_blob())
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Run core updater; for a "young" token with no historicals, deltas remain NULL
|
||||
update_changes_score_volume_and_ranking(&mock)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT change_24h_bp, change_7d_bp, change_24h_usd_bp, change_7d_usd_bp
|
||||
"SELECT change_24h_bp, change_7d_bp, change_24h_usd_bp, change_7d_usd_bp,
|
||||
change_24h_anchor_ts, change_7d_anchor_ts
|
||||
FROM cached_token_metrics WHERE token_id=?",
|
||||
)
|
||||
.bind(token.to_blob())
|
||||
|
|
@ -772,11 +773,361 @@ mod tests {
|
|||
let c7d: Option<i64> = row.get(1);
|
||||
let c24u: Option<i64> = row.get(2);
|
||||
let c7du: Option<i64> = row.get(3);
|
||||
let a24: Option<i64> = row.get(4);
|
||||
let a7d: Option<i64> = row.get(5);
|
||||
|
||||
assert!(c24.is_none());
|
||||
assert!(c7d.is_none());
|
||||
assert!(c24u.is_none());
|
||||
assert!(c7du.is_none());
|
||||
assert_eq!(c24, Some(5000), "24h change should measure since launch");
|
||||
assert_eq!(c7d, Some(5000), "7d change should measure since launch");
|
||||
assert!(c24u.is_some());
|
||||
assert!(c7du.is_some());
|
||||
// Both windows collapsed onto the token's first pool, and say so.
|
||||
assert_eq!(a24, Some(t0));
|
||||
assert_eq!(a7d, Some(t0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_updater_prices_a_long_dormant_token_at_window_rates() {
|
||||
let mock = mock_db_pool(|pool| async move { setup_basic_schemas(pool).await }).await;
|
||||
let pool = &mock.cauldron_w;
|
||||
|
||||
// A token whose pool last moved long before the oracle history begins. Its
|
||||
// BCH change is a genuine 0%, and the USD change is whatever BCH/USD did over
|
||||
// the window — but anchoring the rate to that ancient pool entry found no
|
||||
// oracle sample at all and wrote NULL, so the row showed 0.00% in BCH and
|
||||
// N/A in USD.
|
||||
let now = time_now();
|
||||
let token = TokenID::from_byte_array([0xC3; 32]);
|
||||
let ancient = now - 800 * 86_400;
|
||||
seed_minimal_token_history(
|
||||
pool,
|
||||
token,
|
||||
ancient,
|
||||
ancient + 60,
|
||||
1_000,
|
||||
1_000,
|
||||
2_000,
|
||||
2_000,
|
||||
)
|
||||
.await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO cached_token_metrics(token_id, trade_volume, tvl_sats, tvl_tokens, score,
|
||||
price_now, price_now_usd, updated_at)
|
||||
VALUES (?, 0, 3000, 3000, 0, 1.0, 0.5, strftime('%s','now'))",
|
||||
)
|
||||
.bind(token.to_blob())
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
update_changes_score_volume_and_ranking(&mock)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT change_24h_bp, change_7d_bp, change_24h_usd_bp, change_7d_usd_bp,
|
||||
change_24h_anchor_ts, change_7d_anchor_ts, price_7d_usd
|
||||
FROM cached_token_metrics WHERE token_id=?",
|
||||
)
|
||||
.bind(token.to_blob())
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
row.get::<Option<i64>, _>(0),
|
||||
Some(0),
|
||||
"flat in BCH over 24h"
|
||||
);
|
||||
assert_eq!(row.get::<Option<i64>, _>(1), Some(0), "flat in BCH over 7d");
|
||||
assert!(
|
||||
row.get::<Option<i64>, _>(2).is_some(),
|
||||
"24h USD change must not be N/A when the pool simply held its price"
|
||||
);
|
||||
assert!(
|
||||
row.get::<Option<i64>, _>(3).is_some(),
|
||||
"7d USD change must not be N/A when the pool simply held its price"
|
||||
);
|
||||
assert!(
|
||||
row.get::<Option<f64>, _>(6).is_some(),
|
||||
"7d USD price present"
|
||||
);
|
||||
// The change spans the window, so that is what the anchors report — not the
|
||||
// 800-day-old pool entry the price happens to come from.
|
||||
assert_eq!(row.get::<Option<i64>, _>(4), Some(now - 86_400));
|
||||
assert_eq!(row.get::<Option<i64>, _>(5), Some(now - 7 * 86_400));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_updater_keeps_a_micro_priced_dormant_token_flat_in_usd() {
|
||||
let mock = mock_db_pool(|pool| async move { setup_basic_schemas(pool).await }).await;
|
||||
let pool = &mock.cauldron_w;
|
||||
|
||||
// Sub-1e-9 USD per unit: rounding the historical price to twelve decimal
|
||||
// places leaves one significant digit, and taking the delta from that made a
|
||||
// dormant token report the rounding error as a price move.
|
||||
let now = time_now();
|
||||
let token = TokenID::from_byte_array([0xC4; 32]);
|
||||
seed_minimal_token_history(
|
||||
pool,
|
||||
token,
|
||||
now - 300 * 86_400,
|
||||
now - 299 * 86_400,
|
||||
3,
|
||||
3_000_000,
|
||||
7,
|
||||
9_000_000,
|
||||
)
|
||||
.await;
|
||||
|
||||
// 7/9_000_000 sats per unit at the fixture's $500/BCH is 3.888…e-12 USD, which
|
||||
// twelve decimal places cannot hold — it lands on 4e-12.
|
||||
sqlx::query(
|
||||
"INSERT INTO cached_token_metrics(token_id, trade_volume, tvl_sats, tvl_tokens, score,
|
||||
price_now, price_now_usd, updated_at)
|
||||
VALUES (?, 0, 7, 9000000, 0, 0.000000777777, 0.000000000004, strftime('%s','now'))",
|
||||
)
|
||||
.bind(token.to_blob())
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
update_changes_score_volume_and_ranking(&mock)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT change_24h_usd_bp, change_7d_usd_bp FROM cached_token_metrics WHERE token_id=?",
|
||||
)
|
||||
.bind(token.to_blob())
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The oracle fixture holds one rate, so with the price held the USD move is
|
||||
// exactly zero — any non-zero value here is quantization leaking through.
|
||||
assert_eq!(row.get::<Option<i64>, _>(0), Some(0));
|
||||
assert_eq!(row.get::<Option<i64>, _>(1), Some(0));
|
||||
}
|
||||
|
||||
/// The cached 7d curve, as a client reads it: ratios to price_now.
|
||||
async fn read_price_series(pool: &SqlitePool, token: TokenID) -> Option<Vec<Option<f64>>> {
|
||||
let row = sqlx::query("SELECT price_series_7d FROM cached_token_metrics WHERE token_id=?")
|
||||
.bind(token.to_blob())
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
row.get::<Option<String>, _>(0)
|
||||
.map(|s| serde_json::from_str(&s).unwrap())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_updater_series_agrees_with_the_change_columns() {
|
||||
let mock = mock_db_pool(|pool| async move { setup_basic_schemas(pool).await }).await;
|
||||
let pool = &mock.cauldron_w;
|
||||
|
||||
// Aggregate pool price doubles two days ago: 1000/1000 = 1 sat per base unit
|
||||
// from 9 days ago, then a second pool joins it at 3000/1000 so the summed
|
||||
// reserves become 4000/2000 = 2.
|
||||
let now = time_now();
|
||||
let token = TokenID::from_byte_array([0xC1; 32]);
|
||||
seed_minimal_token_history(
|
||||
pool,
|
||||
token,
|
||||
now - 9 * 86_400,
|
||||
now - 2 * 86_400,
|
||||
1_000,
|
||||
1_000,
|
||||
3_000,
|
||||
1_000,
|
||||
)
|
||||
.await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO cached_token_metrics(token_id, trade_volume, tvl_sats, tvl_tokens, score,
|
||||
price_now, price_now_usd, updated_at)
|
||||
VALUES (?, 0, 4000, 2000, 0, 2.0, 1.0, strftime('%s','now'))",
|
||||
)
|
||||
.bind(token.to_blob())
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
update_changes_score_volume_and_ranking(&mock)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let series = read_price_series(pool, token)
|
||||
.await
|
||||
.expect("a token with a pool at the window start gets a series");
|
||||
assert_eq!(series.len(), 29, "7 days sampled every 6h, both ends");
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT price_7d, price_now, change_7d_bp, change_24h_bp
|
||||
FROM cached_token_metrics WHERE token_id=?",
|
||||
)
|
||||
.bind(token.to_blob())
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
let price_7d: Option<f64> = row.get(0);
|
||||
let price_now: f64 = row.get(1);
|
||||
let change_7d_bp: Option<i64> = row.get(2);
|
||||
let change_24h_bp: Option<i64> = row.get(3);
|
||||
|
||||
// The whole point: the curve's ends ARE the numbers the columns print, so a
|
||||
// client can draw it without the shape contradicting the percentage.
|
||||
let first = series[0].expect("price known at the window start");
|
||||
let last = series[28].expect("price known now");
|
||||
assert_eq!(first, 0.5, "1 sat against a price_now of 2");
|
||||
assert_eq!(last, 1.0, "the last sample is price_now itself");
|
||||
assert_eq!(price_7d, Some(first * price_now));
|
||||
assert_eq!(change_7d_bp, Some(10_000), "price doubled over the week");
|
||||
assert_eq!(
|
||||
((1.0 / first - 1.0) * 10_000.0).round() as i64,
|
||||
change_7d_bp.unwrap(),
|
||||
"change_7d_bp must be recoverable from the first ratio"
|
||||
);
|
||||
|
||||
// A 6h step puts now-24h exactly on index 24, and nothing moved in the last
|
||||
// two days, so that sample and the 1d column agree on "flat".
|
||||
assert_eq!(series[24], Some(1.0));
|
||||
assert_eq!(change_24h_bp, Some(0));
|
||||
|
||||
// The doubling lands on the sample covering now-2d and not the one before it.
|
||||
assert_eq!(series[19], Some(0.5));
|
||||
assert_eq!(series[20], Some(1.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_updater_series_is_null_padded_for_a_young_token() {
|
||||
let mock = mock_db_pool(|pool| async move { setup_basic_schemas(pool).await }).await;
|
||||
let pool = &mock.cauldron_w;
|
||||
|
||||
// Launched three days ago, so the first half of the window predates the token.
|
||||
let now = time_now();
|
||||
let token = TokenID::from_byte_array([0xC2; 32]);
|
||||
seed_minimal_token_history(
|
||||
pool,
|
||||
token,
|
||||
now - 3 * 86_400,
|
||||
now - 86_400,
|
||||
1_000,
|
||||
1_000,
|
||||
3_000,
|
||||
1_000,
|
||||
)
|
||||
.await;
|
||||
|
||||
sqlx::query(
|
||||
"INSERT INTO cached_token_metrics(token_id, trade_volume, tvl_sats, tvl_tokens, score,
|
||||
price_now, price_now_usd, updated_at)
|
||||
VALUES (?, 0, 4000, 2000, 0, 2.0, 1.0, strftime('%s','now'))",
|
||||
)
|
||||
.bind(token.to_blob())
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
update_changes_score_volume_and_ranking(&mock)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let series = read_price_series(pool, token)
|
||||
.await
|
||||
.expect("series present");
|
||||
// now-3d is sample 16; everything before it is unknown rather than invented.
|
||||
assert!(
|
||||
series[..16].iter().all(|v| v.is_none()),
|
||||
"buckets before the first pool must stay null: {series:?}"
|
||||
);
|
||||
assert_eq!(series[16], Some(0.5));
|
||||
assert_eq!(series[28], Some(1.0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn core_updater_keeps_deltas_null_when_token_has_no_history() {
|
||||
let mock = mock_db_pool(|pool| async move { setup_basic_schemas(pool).await }).await;
|
||||
let pool = &mock.cauldron_w;
|
||||
|
||||
// Cached row with liquidity but no pool/tx rows at all: there is no launch to
|
||||
// fall back to, so the deltas must stay NULL rather than be invented.
|
||||
let token = TokenID::from_byte_array([0xCD; 32]);
|
||||
sqlx::query(
|
||||
"INSERT INTO cached_token_metrics(token_id, trade_volume, tvl_sats, tvl_tokens, score,
|
||||
price_now, price_now_usd, updated_at)
|
||||
VALUES (?, 0, 1, 1, 0, 1.0, 0.5, strftime('%s','now'))",
|
||||
)
|
||||
.bind(token.to_blob())
|
||||
.execute(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
update_changes_score_volume_and_ranking(&mock)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let row = sqlx::query(
|
||||
"SELECT change_24h_bp, change_7d_bp, change_24h_usd_bp, change_7d_usd_bp,
|
||||
change_24h_anchor_ts, change_7d_anchor_ts
|
||||
FROM cached_token_metrics WHERE token_id=?",
|
||||
)
|
||||
.bind(token.to_blob())
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for i in 0..6 {
|
||||
let v: Option<i64> = row.get(i);
|
||||
assert!(v.is_none(), "column {i} should be NULL without any history");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn price_anchor_falls_back_to_launch_only_for_young_tokens() {
|
||||
let mock = mock_db_pool(|pool| async move { setup_basic_schemas(pool).await }).await;
|
||||
let pool = &mock.cauldron_r;
|
||||
let now = time_now();
|
||||
let window_start = now - 7 * 86_400;
|
||||
|
||||
// Young: launched inside the window, so the window start has no snapshot.
|
||||
let young = TokenID::from_byte_array([0xC1; 32]);
|
||||
let young_t0 = now - 2 * 86_400;
|
||||
seed_minimal_token_history(pool, young, young_t0, now - 3600, 1_000, 500, 2_000, 1_000)
|
||||
.await;
|
||||
|
||||
// Not knowing first_pool_ts must give the same answer as knowing it — new
|
||||
// tokens are exactly the ones the first_pool_ts backfill hasn't reached yet.
|
||||
let uncached = price_anchor_for_window(pool, window_start, None, &young)
|
||||
.await
|
||||
.expect("young token should anchor at its launch");
|
||||
let cached = price_anchor_for_window(pool, window_start, Some(young_t0), &young)
|
||||
.await
|
||||
.expect("young token should anchor at its launch");
|
||||
assert_eq!(uncached.ts, young_t0);
|
||||
assert_eq!(cached.ts, young_t0);
|
||||
assert_eq!(uncached.price, dec!(2)); // 1_000 sats / 500 base units
|
||||
|
||||
// Old: history reaches past the window start, so the window start wins and the
|
||||
// launch fallback must not kick in.
|
||||
let old = TokenID::from_byte_array([0xC2; 32]);
|
||||
let old_t0 = now - 30 * 86_400;
|
||||
let old_t1 = now - 20 * 86_400;
|
||||
seed_minimal_token_history(pool, old, old_t0, old_t1, 1_000, 500, 2_000, 1_000).await;
|
||||
|
||||
let anchor = price_anchor_for_window(pool, window_start, None, &old)
|
||||
.await
|
||||
.expect("old token has a snapshot at the window start");
|
||||
assert_eq!(
|
||||
anchor.ts, old_t1,
|
||||
"should read the latest snapshot at or before the window start, not the launch"
|
||||
);
|
||||
|
||||
// No pools at all → no anchor to invent.
|
||||
let unknown = TokenID::from_byte_array([0xC3; 32]);
|
||||
assert!(price_anchor_for_window(pool, window_start, None, &unknown)
|
||||
.await
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -10,9 +10,11 @@ use sqlx::SqlitePool;
|
|||
|
||||
use crate::db::blob::display_hex_to_blob;
|
||||
use crate::db::cauldron::pool::{get_injections_between, get_pool_period_snapshot};
|
||||
use crate::db::cauldron::tokenlist::db_utils::db_first_pool_creation_row;
|
||||
use crate::db::oracle::get_closest;
|
||||
use crate::rpc::apy::apyaggregator::APYAggregator;
|
||||
use crate::rpc::apy::poolperiod::split_at_injections;
|
||||
use crate::rpc::price::price_at_or_before_2;
|
||||
use malachite::base::num::arithmetic::traits::FloorSqrt;
|
||||
use malachite::Integer;
|
||||
|
||||
|
|
@ -70,6 +72,70 @@ pub async fn usd_per_bch_at_or_before(
|
|||
Ok(price / Decimal::from_i64(ORACLE_SCALE).unwrap())
|
||||
}
|
||||
|
||||
/// The historical end of a price-change window.
|
||||
///
|
||||
/// `ts` is the timestamp of the snapshot the price was actually read from, which is
|
||||
/// *not* always the window start: for a token younger than the window it is the
|
||||
/// token's first pool instead, and for a dormant one it can be far older than the
|
||||
/// window. Callers persist it so consumers can tell a real 7d move from a
|
||||
/// since-launch one — clamping it to their window first, since a price that merely
|
||||
/// held does not shorten the period being measured.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PriceAnchor {
|
||||
pub ts: i64,
|
||||
pub price: Decimal,
|
||||
}
|
||||
|
||||
async fn price_snapshot_at(
|
||||
cauldron_pool: &SqlitePool,
|
||||
ts: i64,
|
||||
token_id: &TokenID,
|
||||
) -> Option<PriceAnchor> {
|
||||
let (snapshot_ts, price) = price_at_or_before_2(cauldron_pool, ts, token_id)
|
||||
.await
|
||||
.ok()?;
|
||||
Decimal::from_f64(price).map(|price| PriceAnchor {
|
||||
ts: snapshot_ts,
|
||||
price,
|
||||
})
|
||||
}
|
||||
|
||||
/// Price at `window_start`, falling back to the token's first pool when the token is
|
||||
/// younger than the window.
|
||||
///
|
||||
/// Without the fallback every token under 7 days old has a NULL `change_7d_bp` and
|
||||
/// the UI can only show "N/A"; with it a young token reports its change since launch.
|
||||
/// `first_pool_ts` is the cached value when known — it is looked up on demand
|
||||
/// otherwise, since brand-new tokens are exactly the ones the backfill hasn't reached.
|
||||
pub async fn price_anchor_for_window(
|
||||
cauldron_pool: &SqlitePool,
|
||||
window_start: i64,
|
||||
first_pool_ts: Option<i64>,
|
||||
token_id: &TokenID,
|
||||
) -> Option<PriceAnchor> {
|
||||
if let Some(anchor) = price_snapshot_at(cauldron_pool, window_start, token_id).await {
|
||||
return Some(anchor);
|
||||
}
|
||||
|
||||
let launch_ts = match first_pool_ts.filter(|&ts| ts > 0) {
|
||||
Some(ts) => ts,
|
||||
None => db_first_pool_creation_row(cauldron_pool, &token_id.to_string())
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(_creation_utxo, _txid, ts, _height)| ts)?,
|
||||
};
|
||||
|
||||
// History already reaches past the window start, so the miss above is a gap in
|
||||
// the data (e.g. every pool was withdrawn), not a young token. Don't invent an
|
||||
// anchor that would report an older move as if it were a 7d one.
|
||||
if launch_ts <= window_start {
|
||||
return None;
|
||||
}
|
||||
|
||||
price_snapshot_at(cauldron_pool, launch_ts, token_id).await
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn pct_change_bp_dec(current: Decimal, past: Decimal) -> i64 {
|
||||
if past.is_zero() {
|
||||
|
|
|
|||
|
|
@ -235,7 +235,9 @@ pub async fn db_search_tokens_cached(
|
|||
price_now, price_24h, price_7d, change_24h_bp, change_7d_bp,
|
||||
display_name, display_symbol,
|
||||
price_now_usd, price_24h_usd, price_7d_usd, change_24h_usd_bp, change_7d_usd_bp,
|
||||
apy_30d_bp, first_pool_ts, score_rank, bcmr_json, bcmr_well_known_json
|
||||
change_24h_anchor_ts, change_7d_anchor_ts,
|
||||
apy_30d_bp, first_pool_ts, score_rank, bcmr_json, bcmr_well_known_json,
|
||||
price_series_7d
|
||||
FROM cached_token_metrics
|
||||
{where_sql}
|
||||
{order_sql}
|
||||
|
|
@ -291,11 +293,16 @@ pub async fn db_search_tokens_cached(
|
|||
let price_7d_usd: Option<f64> = row.get("price_7d_usd");
|
||||
let change_24h_usd_bp: Option<i64> = row.get("change_24h_usd_bp");
|
||||
let change_7d_usd_bp: Option<i64> = row.get("change_7d_usd_bp");
|
||||
let change_24h_anchor_ts: Option<i64> = row.get("change_24h_anchor_ts");
|
||||
let change_7d_anchor_ts: Option<i64> = row.get("change_7d_anchor_ts");
|
||||
let apy_30d_bp: Option<i64> = row.get("apy_30d_bp");
|
||||
// 0 is the "not backfilled yet" sentinel — treat it as unknown.
|
||||
let first_pool_ts: Option<i64> = row
|
||||
.get::<Option<i64>, _>("first_pool_ts")
|
||||
.filter(|&ts| ts != 0);
|
||||
let price_series_7d: Option<Vec<Option<f64>>> = row
|
||||
.get::<Option<String>, _>("price_series_7d")
|
||||
.and_then(|s| serde_json::from_str(&s).ok());
|
||||
let bcmr_json_str: Option<String> = row.get("bcmr_json");
|
||||
let bcmr_wk_json_str: Option<String> = row.get("bcmr_well_known_json");
|
||||
let bcmr: Option<ParsedBCMR> = bcmr_json_str.and_then(|s| serde_json::from_str(&s).ok());
|
||||
|
|
@ -325,8 +332,11 @@ pub async fn db_search_tokens_cached(
|
|||
price_7d_usd,
|
||||
change_24h_usd_bp,
|
||||
change_7d_usd_bp,
|
||||
change_24h_anchor_ts,
|
||||
change_7d_anchor_ts,
|
||||
apy_30d_bp,
|
||||
first_pool_ts,
|
||||
price_series_7d,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue