Merge branch 'graph-price-inconsistency' into 'master'
Use last trade as the basis for the price outside chart instead of the last token/sats amount See merge request riftenlabs/riftenlabs-indexer!86
This commit is contained in:
commit
b014a1fd89
6 changed files with 1763 additions and 1318 deletions
415
src/db/cauldron/candlestick/mod.rs
Normal file
415
src/db/cauldron/candlestick/mod.rs
Normal file
|
|
@ -0,0 +1,415 @@
|
|||
// Copyright (C) 2024-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
|
||||
|
||||
use crate::db::blob::display_hex_to_blob;
|
||||
use crate::db::cauldron::ohlcv;
|
||||
use anyhow::{bail, Result};
|
||||
use bitcoincash::TokenID;
|
||||
use serde::Serialize;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct CandlestickData {
|
||||
pub time: i64, // start of the interval
|
||||
pub open: f64,
|
||||
pub close: f64,
|
||||
pub high: f64,
|
||||
pub low: f64,
|
||||
pub volume_sats: i64,
|
||||
pub volume_tokens: i64,
|
||||
pub transaction_count: i64,
|
||||
}
|
||||
|
||||
struct PriceInterval {
|
||||
start: i64,
|
||||
step: i64,
|
||||
low: f64,
|
||||
high: f64,
|
||||
open: Option<f64>,
|
||||
close: Option<f64>,
|
||||
volume_sats: i64,
|
||||
volume_tokens: i64,
|
||||
transaction_count: i64,
|
||||
}
|
||||
|
||||
impl PriceInterval {
|
||||
fn new(start: i64, step: i64) -> Self {
|
||||
Self {
|
||||
start,
|
||||
step,
|
||||
low: f64::MAX,
|
||||
high: f64::MIN,
|
||||
open: None,
|
||||
close: None,
|
||||
volume_sats: 0,
|
||||
volume_tokens: 0,
|
||||
transaction_count: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_candlestick_data(&self) -> Option<CandlestickData> {
|
||||
if self.transaction_count == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(CandlestickData {
|
||||
time: self.start,
|
||||
open: self.open?,
|
||||
close: self.close?,
|
||||
high: self.high,
|
||||
low: self.low,
|
||||
volume_sats: self.volume_sats,
|
||||
volume_tokens: self.volume_tokens,
|
||||
transaction_count: self.transaction_count,
|
||||
})
|
||||
}
|
||||
|
||||
fn end(&self) -> i64 {
|
||||
self.start + self.step
|
||||
}
|
||||
}
|
||||
|
||||
fn aggregate_raw_trades(
|
||||
all_trades: &[(i64, i64, i64, i64, i64)],
|
||||
intervals: Vec<PriceInterval>,
|
||||
step_size: i64,
|
||||
mut found_first_trade: bool,
|
||||
mut last_close_price: Option<f64>,
|
||||
) -> (Vec<CandlestickData>, bool, Option<f64>) {
|
||||
let mut result = Vec::with_capacity(intervals.len());
|
||||
let mut trade_index = 0;
|
||||
|
||||
for interval in intervals {
|
||||
let interval_start = interval.start;
|
||||
let interval_end = interval.end();
|
||||
let mut pi = PriceInterval::new(interval_start, step_size);
|
||||
let mut first_trade_in_interval = true;
|
||||
|
||||
while trade_index < all_trades.len() {
|
||||
let (ts, signed_sats, signed_tokens, vol_sats, vol_tokens) = all_trades[trade_index];
|
||||
if ts < interval_start {
|
||||
trade_index += 1;
|
||||
continue;
|
||||
}
|
||||
if ts >= interval_end {
|
||||
break;
|
||||
}
|
||||
|
||||
if signed_tokens != 0 {
|
||||
let price = (signed_sats as f64 / signed_tokens as f64).abs();
|
||||
if first_trade_in_interval {
|
||||
pi.open = Some(price);
|
||||
pi.high = price;
|
||||
pi.low = price;
|
||||
first_trade_in_interval = false;
|
||||
}
|
||||
pi.close = Some(price);
|
||||
if price.is_finite() {
|
||||
pi.high = pi.high.max(price);
|
||||
pi.low = pi.low.min(price);
|
||||
}
|
||||
}
|
||||
|
||||
pi.volume_sats += vol_sats;
|
||||
pi.volume_tokens += vol_tokens;
|
||||
pi.transaction_count += 1;
|
||||
trade_index += 1;
|
||||
}
|
||||
|
||||
// Carry forward last close when volume exists but net tokens are zero.
|
||||
if pi.transaction_count > 0 && (pi.open.is_none() || pi.close.is_none()) {
|
||||
if let Some(prev) = last_close_price {
|
||||
if pi.open.is_none() {
|
||||
pi.open = Some(prev);
|
||||
}
|
||||
if pi.close.is_none() {
|
||||
pi.close = Some(prev);
|
||||
}
|
||||
if pi.high == f64::MIN {
|
||||
pi.high = prev;
|
||||
}
|
||||
if pi.low == f64::MAX {
|
||||
pi.low = prev;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(candle) = pi.to_candlestick_data() {
|
||||
found_first_trade = true;
|
||||
if let Some(close_price) = pi.close {
|
||||
last_close_price = Some(close_price);
|
||||
}
|
||||
result.push(candle);
|
||||
} else if found_first_trade {
|
||||
if let Some(prev_close) = last_close_price {
|
||||
result.push(CandlestickData {
|
||||
time: interval_start,
|
||||
open: prev_close,
|
||||
close: prev_close,
|
||||
high: prev_close,
|
||||
low: prev_close,
|
||||
volume_sats: 0,
|
||||
volume_tokens: 0,
|
||||
transaction_count: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(result, found_first_trade, last_close_price)
|
||||
}
|
||||
|
||||
fn fill_ohlcv_candles(
|
||||
rows: Vec<ohlcv::OhlcvRow>,
|
||||
start: i64,
|
||||
end: i64,
|
||||
mut found_first_trade: bool,
|
||||
mut last_close: Option<f64>,
|
||||
) -> (Vec<CandlestickData>, bool, Option<f64>) {
|
||||
let mut result = Vec::new();
|
||||
let mut row_iter = rows.into_iter().peekable();
|
||||
let mut bucket = start;
|
||||
|
||||
while bucket < end {
|
||||
if row_iter.peek().map(|r| r.bucket_ts) == Some(bucket) {
|
||||
let r = row_iter.next().unwrap();
|
||||
found_first_trade = true;
|
||||
last_close = Some(r.close);
|
||||
result.push(CandlestickData {
|
||||
time: r.bucket_ts,
|
||||
open: r.open,
|
||||
close: r.close,
|
||||
high: r.high,
|
||||
low: r.low,
|
||||
volume_sats: r.volume_sats,
|
||||
volume_tokens: r.volume_tokens,
|
||||
transaction_count: r.tx_count,
|
||||
});
|
||||
} else if found_first_trade {
|
||||
if let Some(prev) = last_close {
|
||||
result.push(CandlestickData {
|
||||
time: bucket,
|
||||
open: prev,
|
||||
close: prev,
|
||||
high: prev,
|
||||
low: prev,
|
||||
volume_sats: 0,
|
||||
volume_tokens: 0,
|
||||
transaction_count: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
bucket += 3600;
|
||||
}
|
||||
|
||||
(result, found_first_trade, last_close)
|
||||
}
|
||||
|
||||
async fn fetch_raw_trades(
|
||||
pool: &SqlitePool,
|
||||
token_blob: &[u8],
|
||||
timestamp_start: i64,
|
||||
timestamp_end: i64,
|
||||
) -> Result<Vec<(i64, i64, i64, i64, i64)>> {
|
||||
let sql = r#"
|
||||
WITH per_pool_tx_raw AS (
|
||||
SELECT
|
||||
phe.txid,
|
||||
phe.effective_timestamp,
|
||||
phe.utxo,
|
||||
phe.sats_delta,
|
||||
phe.token_delta,
|
||||
phe.sequence
|
||||
FROM pool_history_entry AS phe
|
||||
WHERE phe.token_id = ?
|
||||
AND phe.effective_timestamp >= ?
|
||||
AND phe.effective_timestamp < ?
|
||||
),
|
||||
per_pool_tx AS (
|
||||
SELECT
|
||||
txid,
|
||||
effective_timestamp,
|
||||
utxo,
|
||||
MIN(sequence) AS min_sequence,
|
||||
SUM(sats_delta) AS signed_sats_pool,
|
||||
SUM(token_delta) AS signed_tokens_pool,
|
||||
SUM(ABS(sats_delta)) AS volume_sats_pool,
|
||||
SUM(ABS(token_delta)) AS volume_tokens_pool
|
||||
FROM per_pool_tx_raw
|
||||
GROUP BY txid, effective_timestamp, utxo
|
||||
),
|
||||
tx_trades AS (
|
||||
SELECT
|
||||
txid,
|
||||
effective_timestamp,
|
||||
MIN(min_sequence) AS min_sequence,
|
||||
SUM(signed_sats_pool) AS signed_sats,
|
||||
SUM(signed_tokens_pool) AS signed_tokens,
|
||||
SUM(volume_sats_pool) AS volume_sats,
|
||||
SUM(volume_tokens_pool) AS volume_tokens
|
||||
FROM per_pool_tx
|
||||
GROUP BY txid, effective_timestamp
|
||||
)
|
||||
SELECT
|
||||
effective_timestamp,
|
||||
signed_sats,
|
||||
signed_tokens,
|
||||
volume_sats,
|
||||
volume_tokens
|
||||
FROM tx_trades
|
||||
ORDER BY effective_timestamp ASC, min_sequence ASC;
|
||||
"#;
|
||||
let rows = sqlx::query(sql)
|
||||
.bind(token_blob)
|
||||
.bind(timestamp_start)
|
||||
.bind(timestamp_end)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| (r.get(0), r.get(1), r.get(2), r.get(3), r.get(4)))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Returns the close price of the most recent priceable trade strictly before
|
||||
/// `timestamp_end`, using the same per-tx aggregation as `fetch_raw_trades`.
|
||||
/// Returns `None` when no prior trade exists (new token, no history).
|
||||
async fn fetch_last_close_before(
|
||||
pool: &SqlitePool,
|
||||
token_blob: &[u8],
|
||||
timestamp_end: i64,
|
||||
) -> Result<Option<f64>> {
|
||||
let sql = r#"
|
||||
WITH per_pool_tx_raw AS (
|
||||
SELECT
|
||||
phe.txid,
|
||||
phe.effective_timestamp,
|
||||
phe.utxo,
|
||||
phe.sats_delta,
|
||||
phe.token_delta,
|
||||
phe.sequence
|
||||
FROM pool_history_entry AS phe
|
||||
WHERE phe.token_id = ?
|
||||
AND phe.effective_timestamp < ?
|
||||
),
|
||||
per_pool_tx AS (
|
||||
SELECT
|
||||
txid,
|
||||
effective_timestamp,
|
||||
utxo,
|
||||
MIN(sequence) AS min_sequence,
|
||||
SUM(sats_delta) AS signed_sats_pool,
|
||||
SUM(token_delta) AS signed_tokens_pool
|
||||
FROM per_pool_tx_raw
|
||||
GROUP BY txid, effective_timestamp, utxo
|
||||
),
|
||||
tx_trades AS (
|
||||
SELECT
|
||||
txid,
|
||||
effective_timestamp,
|
||||
MIN(min_sequence) AS min_sequence,
|
||||
SUM(signed_sats_pool) AS signed_sats,
|
||||
SUM(signed_tokens_pool) AS signed_tokens
|
||||
FROM per_pool_tx
|
||||
GROUP BY txid, effective_timestamp
|
||||
)
|
||||
SELECT ABS(CAST(signed_sats AS REAL) / CAST(signed_tokens AS REAL)) AS close_price
|
||||
FROM tx_trades
|
||||
WHERE signed_tokens != 0
|
||||
ORDER BY effective_timestamp DESC, min_sequence DESC
|
||||
LIMIT 1
|
||||
"#;
|
||||
let row = sqlx::query(sql)
|
||||
.bind(token_blob)
|
||||
.bind(timestamp_end)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| r.get::<f64, _>(0)))
|
||||
}
|
||||
|
||||
/// `ohlcv_materialized_end`: exclusive upper bound of what is in `ohlcv_1h`.
|
||||
/// Pass 0 to always use the raw CTE path.
|
||||
pub async fn candlesticks(
|
||||
pool: &SqlitePool,
|
||||
timestamp_start: i64,
|
||||
timestamp_end: i64,
|
||||
step_size: i64,
|
||||
token_id: &str,
|
||||
ohlcv_materialized_end: i64,
|
||||
) -> Result<Vec<CandlestickData>> {
|
||||
if timestamp_start > timestamp_end {
|
||||
bail!("Start cannot be higher than end");
|
||||
}
|
||||
|
||||
let token_blob = display_hex_to_blob::<TokenID>(token_id)?;
|
||||
|
||||
// Seed gap-fill with the last known close price before this window so that
|
||||
// switching between timeframes (e.g. 1W vs 1M) produces consistent prices
|
||||
// for any overlapping period.
|
||||
let seed_close = fetch_last_close_before(pool, &token_blob, timestamp_start).await?;
|
||||
let seed_found = seed_close.is_some();
|
||||
|
||||
// Fast path: use pre-materialised ohlcv_1h when step_size is exactly 1 hour,
|
||||
// ohlcv covers at least part of the range, AND the start is hour-aligned.
|
||||
// ohlcv_1h buckets are always aligned to multiples of 3600, so a non-aligned
|
||||
// start would produce candles whose boundaries disagree with the raw path.
|
||||
if step_size == 3600 && ohlcv_materialized_end > timestamp_start && timestamp_start % 3600 == 0
|
||||
{
|
||||
let ohlcv_end = ohlcv_materialized_end.min(timestamp_end);
|
||||
|
||||
// timestamp_start is guaranteed hour-aligned by the entry condition above.
|
||||
let ohlcv_rows =
|
||||
ohlcv::get_active_candles(pool, &token_blob, timestamp_start, ohlcv_end).await?;
|
||||
|
||||
let (mut result, found_first, last_close) = fill_ohlcv_candles(
|
||||
ohlcv_rows,
|
||||
timestamp_start,
|
||||
ohlcv_end,
|
||||
seed_found,
|
||||
seed_close,
|
||||
);
|
||||
|
||||
if ohlcv_end < timestamp_end {
|
||||
// Tail: query raw for [ohlcv_end, timestamp_end) and append.
|
||||
let raw_trades = fetch_raw_trades(pool, &token_blob, ohlcv_end, timestamp_end).await?;
|
||||
|
||||
let mut tail_intervals = Vec::new();
|
||||
let mut t = ohlcv_end;
|
||||
while t < timestamp_end {
|
||||
tail_intervals.push(PriceInterval::new(t, step_size));
|
||||
t += step_size;
|
||||
}
|
||||
|
||||
let (tail, _, _) = aggregate_raw_trades(
|
||||
&raw_trades,
|
||||
tail_intervals,
|
||||
step_size,
|
||||
found_first,
|
||||
last_close,
|
||||
);
|
||||
result.extend(tail);
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
// Raw path: full CTE scan (all non-3600 step sizes, or when ohlcv is not ready).
|
||||
let mut intervals = Vec::new();
|
||||
let mut current_start = timestamp_start;
|
||||
while current_start < timestamp_end {
|
||||
intervals.push(PriceInterval::new(current_start, step_size));
|
||||
current_start += step_size;
|
||||
}
|
||||
|
||||
let all_trades = fetch_raw_trades(pool, &token_blob, timestamp_start, timestamp_end).await?;
|
||||
|
||||
let (result, _, _) =
|
||||
aggregate_raw_trades(&all_trades, intervals, step_size, seed_found, seed_close);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
195
src/db/cauldron/candlestick/tests.rs
Normal file
195
src/db/cauldron/candlestick/tests.rs
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// Copyright (C) 2024-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
|
||||
|
||||
use crate::db::blob::ToBlob;
|
||||
use crate::db::cauldron::{
|
||||
ohlcv,
|
||||
pool::{self, dummy_init_seq},
|
||||
tx::{self, insert_block_tx, insert_mempool_tx},
|
||||
utxo_funding::{self, insert_utxo_funding},
|
||||
};
|
||||
use crate::utiltest::mock_db_pool;
|
||||
use bitcoin_hashes::Hash;
|
||||
use bitcoincash::{BlockHash, PubkeyHash, TokenID, Txid};
|
||||
use riftenlabs_defi::{cauldron::ParsedContract, chainutil::OutPointHash};
|
||||
|
||||
fn dummy_cauldron(
|
||||
txid: &Txid,
|
||||
utxo: &OutPointHash,
|
||||
token: &TokenID,
|
||||
sats: u64,
|
||||
tokens: i64,
|
||||
pkh: &PubkeyHash,
|
||||
) -> ParsedContract {
|
||||
ParsedContract {
|
||||
pkh: *pkh,
|
||||
is_withdrawn: false,
|
||||
spent_utxo_hash: OutPointHash::all_zeros(),
|
||||
new_utxo_hash: Some(*utxo),
|
||||
new_utxo_txid: Some(*txid),
|
||||
new_utxo_n: Some(0),
|
||||
token_id: Some(*token),
|
||||
sats: Some(sats),
|
||||
token_amount: Some(tokens),
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup_db(pool: sqlx::SqlitePool) {
|
||||
utxo_funding::create_table(&pool).await;
|
||||
tx::create_table(&pool).await;
|
||||
pool::create_table(&pool).await;
|
||||
ohlcv::create_table(&pool).await;
|
||||
dummy_init_seq();
|
||||
}
|
||||
|
||||
async fn insert_trade_at(
|
||||
conn: &mut sqlx::pool::PoolConnection<sqlx::Sqlite>,
|
||||
token: &TokenID,
|
||||
txid_byte: u8,
|
||||
ts: u64,
|
||||
sats_delta: i64,
|
||||
token_delta: i64,
|
||||
) {
|
||||
let txid = Txid::from_byte_array([txid_byte; 32]);
|
||||
let utxo = OutPointHash::from_byte_array([txid_byte; 32]);
|
||||
let pool_hash = OutPointHash::from_byte_array([txid_byte.wrapping_add(0x80); 32]);
|
||||
let block = BlockHash::all_zeros();
|
||||
|
||||
let cauldron = dummy_cauldron(
|
||||
&txid,
|
||||
&utxo,
|
||||
token,
|
||||
sats_delta.unsigned_abs(),
|
||||
token_delta,
|
||||
&PubkeyHash::all_zeros(),
|
||||
);
|
||||
|
||||
insert_utxo_funding(&mut **conn, &vec![cauldron.clone()], &txid)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_mempool_tx(&mut **conn, &txid, ts).await.unwrap();
|
||||
insert_block_tx(&mut **conn, &txid, &block, ts as i64)
|
||||
.await
|
||||
.unwrap();
|
||||
pool::insert_pool_history_entry(
|
||||
&mut **conn,
|
||||
&pool_hash,
|
||||
&cauldron,
|
||||
Some(ts),
|
||||
Some(ts),
|
||||
sats_delta,
|
||||
token_delta,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_last_close_before_no_trades() {
|
||||
let db = mock_db_pool(setup_db).await;
|
||||
let token = TokenID::from_byte_array([0xAA; 32]);
|
||||
let token_blob = token.to_blob();
|
||||
|
||||
let result = super::fetch_last_close_before(&db.cauldron_r, &token_blob, 2_000_000_000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_last_close_before_only_future_trades() {
|
||||
let db = mock_db_pool(setup_db).await;
|
||||
let token = TokenID::from_byte_array([0xAB; 32]);
|
||||
let token_blob = token.to_blob();
|
||||
|
||||
let mut conn = db.cauldron_w.acquire().await.unwrap();
|
||||
insert_trade_at(&mut conn, &token, 0x01, 2000, 100_000, 2_000).await;
|
||||
|
||||
let result = super::fetch_last_close_before(&db.cauldron_r, &token_blob, 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_last_close_before_returns_most_recent() {
|
||||
let db = mock_db_pool(setup_db).await;
|
||||
let token = TokenID::from_byte_array([0xAC; 32]);
|
||||
let token_blob = token.to_blob();
|
||||
|
||||
let mut conn = db.cauldron_w.acquire().await.unwrap();
|
||||
// ts=1000: price = 40_000/2_000 = 20
|
||||
insert_trade_at(&mut conn, &token, 0x01, 1000, 40_000, 2_000).await;
|
||||
// ts=2000: price = 100_000/2_000 = 50 ← most recent before 3000
|
||||
insert_trade_at(&mut conn, &token, 0x02, 2000, 100_000, 2_000).await;
|
||||
// ts=4000: after cutoff, must be excluded
|
||||
insert_trade_at(&mut conn, &token, 0x03, 4000, 200_000, 2_000).await;
|
||||
|
||||
let result = super::fetch_last_close_before(&db.cauldron_r, &token_blob, 3000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_some());
|
||||
assert!((result.unwrap() - 50.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
/// Trade exactly AT timestamp_end must be excluded — the query uses strict `<`.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_last_close_before_boundary_excluded() {
|
||||
let db = mock_db_pool(setup_db).await;
|
||||
let token = TokenID::from_byte_array([0xAD; 32]);
|
||||
let token_blob = token.to_blob();
|
||||
|
||||
let mut conn = db.cauldron_w.acquire().await.unwrap();
|
||||
insert_trade_at(&mut conn, &token, 0x04, 1000, 100_000, 2_000).await;
|
||||
|
||||
// Query exactly at ts=1000: that trade must NOT be included (strict <).
|
||||
let result = super::fetch_last_close_before(&db.cauldron_r, &token_blob, 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none(), "trade at cutoff must be excluded");
|
||||
}
|
||||
|
||||
/// Trades with net-zero token delta (signed_tokens == 0) are invisible to pricing.
|
||||
/// Only the last priceable trade before the cutoff should be returned.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_last_close_before_skips_net_zero_token_trades() {
|
||||
let db = mock_db_pool(setup_db).await;
|
||||
let token = TokenID::from_byte_array([0xAE; 32]);
|
||||
let token_blob = token.to_blob();
|
||||
|
||||
let mut conn = db.cauldron_w.acquire().await.unwrap();
|
||||
// ts=500: priceable trade, price = 50_000/1_000 = 50
|
||||
insert_trade_at(&mut conn, &token, 0x05, 500, 50_000, 1_000).await;
|
||||
// ts=800: net-zero token trade — should be invisible to pricing
|
||||
insert_trade_at(&mut conn, &token, 0x06, 800, 10_000, 0).await;
|
||||
|
||||
let result = super::fetch_last_close_before(&db.cauldron_r, &token_blob, 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_some());
|
||||
// Must return the priceable trade's close (50), not be confused by the net-zero one.
|
||||
assert!(
|
||||
(result.unwrap() - 50.0).abs() < f64::EPSILON,
|
||||
"net-zero trade must not affect close price"
|
||||
);
|
||||
}
|
||||
|
||||
/// Trades for a different token must not bleed into results for the queried token.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_last_close_before_token_isolation() {
|
||||
let db = mock_db_pool(setup_db).await;
|
||||
let token_a = TokenID::from_byte_array([0xAF; 32]);
|
||||
let token_b = TokenID::from_byte_array([0xBF; 32]);
|
||||
let token_a_blob = token_a.to_blob();
|
||||
|
||||
let mut conn = db.cauldron_w.acquire().await.unwrap();
|
||||
// Only insert a trade for token_b; token_a has nothing.
|
||||
insert_trade_at(&mut conn, &token_b, 0x07, 500, 100_000, 2_000).await;
|
||||
|
||||
let result = super::fetch_last_close_before(&db.cauldron_r, &token_a_blob, 1000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.is_none(), "other token's trade must not appear");
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ use crate::db::cauldron::tokenlist::db_utils::{
|
|||
create_cached_token_metrics_table,
|
||||
};
|
||||
|
||||
pub mod candlestick;
|
||||
pub mod config;
|
||||
pub mod header;
|
||||
pub mod mempool;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
136
src/rpc/candlesticks/mod.rs
Normal file
136
src/rpc/candlesticks/mod.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
// Copyright (C) 2024-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
|
||||
|
||||
use crate::db::cauldron::candlestick::candlesticks;
|
||||
use crate::db::DB;
|
||||
use crate::rpc::err::{bad_request, ApiErrorCode, CachedApiResult};
|
||||
use crate::rpc::response::{cached_ok, CACHE_IMMUTABLE, CACHE_NONE};
|
||||
use crate::timeutil::time_now;
|
||||
use crate::OhlcvState;
|
||||
use rocket::{get, State};
|
||||
use serde_json::json;
|
||||
use serde_json::Value;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Fetch candlesticks in BCH satoshis for a token.
|
||||
///
|
||||
/// If an interval has no trades, it will be omitted from the result.
|
||||
///
|
||||
///
|
||||
/// - start: unix timestamp for period start (default 30 days)
|
||||
/// - end: unix timestamp for period end (default NOW)
|
||||
/// - stepsize: seconds per interval (default: 3600 seconds)
|
||||
///
|
||||
/// **Response Example:**
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "candlesticks": [
|
||||
/// {"close":64654136.35714286,
|
||||
/// "high":87959043.0,
|
||||
/// "low":58755326.0,
|
||||
/// "open":87959043.0,
|
||||
/// "time":1752522150,
|
||||
/// "transaction_count":4,
|
||||
/// "volume_sats":3170247594,
|
||||
/// "volume_tokens":43},
|
||||
/// ]
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
#[get("/price/<token>/candlesticks?<start>&<end>&<stepsize>")]
|
||||
pub async fn price_candlesticks(
|
||||
token: &str,
|
||||
start: Option<i64>,
|
||||
end: Option<i64>,
|
||||
stepsize: Option<i64>,
|
||||
conn: &State<DB>,
|
||||
ohlcv: &State<Arc<OhlcvState>>,
|
||||
) -> CachedApiResult<Value> {
|
||||
let current_timestamp = time_now();
|
||||
|
||||
if let Some(end_ts) = end {
|
||||
if end_ts > current_timestamp {
|
||||
return Err(bad_request(
|
||||
ApiErrorCode::FutureTimestamp,
|
||||
"End timestamp cannot be in the future",
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let effective_end = end.unwrap_or(current_timestamp);
|
||||
let effective_start = start.unwrap_or(current_timestamp - 30 * 24 * 3600);
|
||||
let effective_stepsize = stepsize.unwrap_or(3600);
|
||||
|
||||
if effective_stepsize <= 0 {
|
||||
return Err(bad_request(
|
||||
ApiErrorCode::InvalidStepsize,
|
||||
"stepsize must be > 0",
|
||||
));
|
||||
}
|
||||
|
||||
if effective_start >= effective_end {
|
||||
return Err(bad_request(
|
||||
ApiErrorCode::InvalidTimeRange,
|
||||
"Start timestamp must be before end timestamp",
|
||||
));
|
||||
}
|
||||
|
||||
const MAX_INTERVALS: i64 = 10000;
|
||||
let total_intervals = (effective_end - effective_start) / effective_stepsize;
|
||||
if total_intervals > MAX_INTERVALS {
|
||||
return Err(bad_request(
|
||||
ApiErrorCode::TooManyIntervals,
|
||||
&format!("Too many intervals ({total_intervals} > {MAX_INTERVALS})"),
|
||||
));
|
||||
}
|
||||
|
||||
// Windows fully in the past get immutable HTTP cache headers.
|
||||
let is_historical = end.is_some() && effective_end < current_timestamp - effective_stepsize;
|
||||
|
||||
let ohlcv_end = ohlcv.materialized_end.load(Ordering::Relaxed);
|
||||
|
||||
let candlestick_data = candlesticks(
|
||||
&conn.cauldron_r,
|
||||
effective_start,
|
||||
effective_end,
|
||||
effective_stepsize,
|
||||
token,
|
||||
ohlcv_end,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| bad_request(ApiErrorCode::InvalidParameters, &e.to_string()))?;
|
||||
|
||||
let candlesticks_json: Vec<Value> = candlestick_data
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
json!({
|
||||
"time": c.time,
|
||||
"high": c.high,
|
||||
"low": c.low,
|
||||
"open": c.open,
|
||||
"close": c.close,
|
||||
"volume_sats": c.volume_sats,
|
||||
"volume_tokens": c.volume_tokens,
|
||||
"transaction_count": c.transaction_count
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let cache_duration = if is_historical {
|
||||
CACHE_IMMUTABLE
|
||||
} else {
|
||||
CACHE_NONE
|
||||
};
|
||||
|
||||
Ok(cached_ok(
|
||||
json!({ "candlesticks": candlesticks_json }),
|
||||
cache_duration,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
1016
src/rpc/candlesticks/tests.rs
Normal file
1016
src/rpc/candlesticks/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue