Nice spot

This commit is contained in:
jakobsn 2026-08-11 15:03:50 +02:00
parent 0a969b296b
commit bff99822a3
6 changed files with 1628 additions and 844 deletions

View file

@ -3,8 +3,11 @@
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later. // 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 // A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use std::collections::HashMap;
use crate::db::blob::display_hex_to_blob; use crate::db::blob::display_hex_to_blob;
use crate::db::cauldron::ohlcv; use crate::db::cauldron::ohlcv;
use crate::db::cauldron::spot::{self, build_spot_ohlc, Confirmed, SpotState};
use anyhow::{bail, Result}; use anyhow::{bail, Result};
use bitcoincash::TokenID; use bitcoincash::TokenID;
use serde::Serialize; use serde::Serialize;
@ -22,159 +25,28 @@ pub struct CandlestickData {
pub transaction_count: i64, pub transaction_count: i64,
} }
struct PriceInterval { /// Fills the buckets `ohlcv_1h` does not store.
start: i64, ///
step: i64, /// Only buckets containing a pool change are materialised; a bucket with no events
low: f64, /// repeats the previous close exactly, so it is reconstructed here. `seed` is the
high: f64, /// price entering the window — without it, a window that opens on a quiet stretch
open: Option<f64>, /// would start blank and disagree with the same period viewed at another timeframe.
close: Option<f64>, ///
volume_sats: i64, /// Returns the candles and the close carried out of the window.
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)],
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, vol_sats, vol_tokens) = all_trades[trade_index];
if ts < interval_start {
trade_index += 1;
continue;
}
if ts >= interval_end {
break;
}
if vol_tokens != 0 {
let price = vol_sats as f64 / vol_tokens as f64;
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( fn fill_ohlcv_candles(
rows: Vec<ohlcv::OhlcvRow>, rows: Vec<ohlcv::OhlcvRow>,
start: i64, start: i64,
end: i64, end: i64,
mut found_first_trade: bool, seed: Option<f64>,
mut last_close: Option<f64>, ) -> (Vec<CandlestickData>, Option<f64>) {
) -> (Vec<CandlestickData>, bool, Option<f64>) {
let mut result = Vec::new(); let mut result = Vec::new();
let mut row_iter = rows.into_iter().peekable(); let mut row_iter = rows.into_iter().peekable();
let mut last_close = seed;
let mut bucket = start; let mut bucket = start;
while bucket < end { while bucket < end {
if row_iter.peek().map(|r| r.bucket_ts) == Some(bucket) { if row_iter.peek().map(|r| r.bucket_ts) == Some(bucket) {
let r = row_iter.next().unwrap(); let r = row_iter.next().unwrap();
found_first_trade = true;
last_close = Some(r.close); last_close = Some(r.close);
result.push(CandlestickData { result.push(CandlestickData {
time: r.bucket_ts, time: r.bucket_ts,
@ -186,36 +58,34 @@ fn fill_ohlcv_candles(
volume_tokens: r.volume_tokens, volume_tokens: r.volume_tokens,
transaction_count: r.tx_count, transaction_count: r.tx_count,
}); });
} else if found_first_trade { } else if let Some(prev) = last_close {
if let Some(prev) = last_close { result.push(CandlestickData {
result.push(CandlestickData { time: bucket,
time: bucket, open: prev,
open: prev, close: prev,
close: prev, high: prev,
high: prev, low: prev,
low: prev, volume_sats: 0,
volume_sats: 0, volume_tokens: 0,
volume_tokens: 0, transaction_count: 0,
transaction_count: 0, });
});
}
} }
bucket += 3600; bucket += 3600;
} }
(result, found_first_trade, last_close) (result, last_close)
} }
/// Returns one row per transaction: `(effective_timestamp, volume_sats, volume_tokens)`. /// Returns one row per transaction: `(effective_timestamp, volume_sats, volume_tokens)`.
/// ///
/// Volumes are gross sums of the absolute per-leg deltas, so the derived price /// Volumes are gross sums of the absolute per-leg deltas. Summing the *signed* deltas
/// `volume_sats / volume_tokens` is the volume-weighted average of the prices actually /// instead lets a multi-pool arbitrage transaction — which buys from one pool and
/// executed by that transaction's legs, and is therefore always bounded by the cheapest /// sells into others — cancel almost all of its token movement and report a fraction
/// and dearest leg. Summing the *signed* deltas instead lets a multi-pool arbitrage /// of the volume it really moved.
/// transaction — which buys from one pool and sells into others — cancel almost all of ///
/// its token movement and divide real satoshis by a near-zero remainder, fabricating a /// Unlike `ohlcv_1h`, this path includes mempool transactions, so a trade shows up in
/// price no leg ever traded at. /// the newest candle as soon as it is seen.
async fn fetch_raw_trades( async fn fetch_tx_volumes(
pool: &SqlitePool, pool: &SqlitePool,
token_blob: &[u8], token_blob: &[u8],
timestamp_start: i64, timestamp_start: i64,
@ -247,36 +117,59 @@ ORDER BY phe.effective_timestamp ASC, min_sequence ASC;
.collect()) .collect())
} }
/// Returns the close price of the most recent priceable trade strictly before /// Candles for `[start, end)` built straight from `pool_history_entry`.
/// `timestamp_end`, using the same per-tx aggregation as `fetch_raw_trades`. ///
/// Returns `None` when no prior trade exists (new token, no history). /// Prices come from replaying the aggregate pool spot price; volumes from the gross
async fn fetch_last_close_before( /// per-transaction sums. The two are independent on purpose: a withdrawal moves the
/// price with no volume, and a trade that nets to zero tokens still moved satoshis.
async fn raw_candles(
pool: &SqlitePool, pool: &SqlitePool,
token_blob: &[u8], token_blob: &[u8],
timestamp_end: i64, start: i64,
) -> Result<Option<f64>> { end: i64,
let sql = r#" step_size: i64,
SELECT ) -> Result<Vec<CandlestickData>> {
CAST(SUM(ABS(phe.sats_delta)) AS REAL) / CAST(SUM(ABS(phe.token_delta)) AS REAL) AS close_price let reserves = spot::load_snapshot(pool, Some(token_blob), start, Confirmed::OrMempool)
FROM pool_history_entry AS phe .await?
WHERE phe.token_id = ? .remove(token_blob)
AND phe.effective_timestamp < ? .unwrap_or_default();
GROUP BY phe.txid, phe.effective_timestamp let events = spot::load_events(pool, Some(token_blob), start, end, Confirmed::OrMempool)
HAVING SUM(ABS(phe.token_delta)) != 0 .await?
ORDER BY phe.effective_timestamp DESC, MIN(phe.sequence) DESC .remove(token_blob)
LIMIT 1 .unwrap_or_default();
"#;
let row = sqlx::query(sql)
.bind(token_blob)
.bind(timestamp_end)
.fetch_optional(pool)
.await?;
Ok(row.map(|r| r.get::<f64, _>(0))) let mut volumes: HashMap<i64, (i64, i64, i64)> = HashMap::new();
for (ts, vol_sats, vol_tokens) in fetch_tx_volumes(pool, token_blob, start, end).await? {
let bucket = start + ((ts - start) / step_size) * step_size;
let entry = volumes.entry(bucket).or_insert((0, 0, 0));
entry.0 += vol_sats;
entry.1 += vol_tokens;
entry.2 += 1;
}
Ok(
build_spot_ohlc(SpotState::new(reserves), &events, start, end, step_size)
.into_iter()
.map(|c| {
let (volume_sats, volume_tokens, transaction_count) =
volumes.get(&c.time).copied().unwrap_or((0, 0, 0));
CandlestickData {
time: c.time,
open: c.open,
close: c.close,
high: c.high,
low: c.low,
volume_sats,
volume_tokens,
transaction_count,
}
})
.collect(),
)
} }
/// `ohlcv_materialized_end`: exclusive upper bound of what is in `ohlcv_1h`. /// `ohlcv_materialized_end`: exclusive upper bound of what is in `ohlcv_1h`.
/// Pass 0 to always use the raw CTE path. /// Pass 0 to always use the raw path.
pub async fn candlesticks( pub async fn candlesticks(
pool: &SqlitePool, pool: &SqlitePool,
timestamp_start: i64, timestamp_start: i64,
@ -291,12 +184,6 @@ pub async fn candlesticks(
let token_blob = display_hex_to_blob::<TokenID>(token_id)?; 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, // 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 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 // ohlcv_1h buckets are always aligned to multiples of 3600, so a non-aligned
@ -305,55 +192,39 @@ pub async fn candlesticks(
{ {
let ohlcv_end = ohlcv_materialized_end.min(timestamp_end); let ohlcv_end = ohlcv_materialized_end.min(timestamp_end);
// Seed gap-fill with the pool price entering the window, so switching between
// timeframes (e.g. 1W vs 1M) produces the same prices for the overlap.
let seed = SpotState::new(
spot::load_snapshot(
pool,
Some(&token_blob),
timestamp_start,
Confirmed::OrMempool,
)
.await?
.remove(&token_blob)
.unwrap_or_default(),
)
.price();
// timestamp_start is guaranteed hour-aligned by the entry condition above. // timestamp_start is guaranteed hour-aligned by the entry condition above.
let ohlcv_rows = let ohlcv_rows =
ohlcv::get_active_candles(pool, &token_blob, timestamp_start, ohlcv_end).await?; ohlcv::get_active_candles(pool, &token_blob, timestamp_start, ohlcv_end).await?;
let (mut result, found_first, last_close) = fill_ohlcv_candles( let (mut result, _) = fill_ohlcv_candles(ohlcv_rows, timestamp_start, ohlcv_end, seed);
ohlcv_rows,
timestamp_start,
ohlcv_end,
seed_found,
seed_close,
);
if ohlcv_end < timestamp_end { if ohlcv_end < timestamp_end {
// Tail: query raw for [ohlcv_end, timestamp_end) and append. // Tail: replay raw for [ohlcv_end, timestamp_end) and append. It takes its
let raw_trades = fetch_raw_trades(pool, &token_blob, ohlcv_end, timestamp_end).await?; // own snapshot at `ohlcv_end`, which is the same aggregate price the last
// materialised bucket closed at, so the join is seamless.
let mut tail_intervals = Vec::new(); result
let mut t = ohlcv_end; .extend(raw_candles(pool, &token_blob, ohlcv_end, timestamp_end, step_size).await?);
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); return Ok(result);
} }
// Raw path: full CTE scan (all non-3600 step sizes, or when ohlcv is not ready). raw_candles(pool, &token_blob, timestamp_start, timestamp_end, step_size).await
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)] #[cfg(test)]

View file

@ -4,16 +4,21 @@
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html // 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::blob::ToBlob;
use crate::db::cauldron::spot::{self, Confirmed, SpotState};
use crate::db::cauldron::{ use crate::db::cauldron::{
ohlcv, ohlcv,
pool::{self, dummy_init_seq}, pool::{self, dummy_init_seq},
tx::{self, insert_block_tx, insert_mempool_tx}, tx::{self, insert_block_tx, insert_mempool_tx},
utxo_funding::{self, insert_utxo_funding}, utxo_funding::{self, insert_utxo_funding},
utxo_spending,
}; };
use crate::utiltest::mock_db_pool; use crate::utiltest::mock_db_pool;
use bitcoin_hashes::Hash; use bitcoin_hashes::Hash;
use bitcoincash::{BlockHash, PubkeyHash, TokenID, Txid}; use bitcoincash::{BlockHash, PubkeyHash, TokenID, Txid};
use riftenlabs_defi::{cauldron::ParsedContract, chainutil::OutPointHash}; use riftenlabs_defi::{cauldron::ParsedContract, chainutil::OutPointHash};
use sqlx::SqlitePool;
const HOUR: u64 = 3600;
fn dummy_cauldron( fn dummy_cauldron(
txid: &Txid, txid: &Txid,
@ -38,320 +43,554 @@ fn dummy_cauldron(
async fn setup_db(pool: sqlx::SqlitePool) { async fn setup_db(pool: sqlx::SqlitePool) {
utxo_funding::create_table(&pool).await; utxo_funding::create_table(&pool).await;
utxo_spending::create_table(&pool).await;
tx::create_table(&pool).await; tx::create_table(&pool).await;
pool::create_table(&pool).await; pool::create_table(&pool).await;
ohlcv::create_table(&pool).await; ohlcv::create_table(&pool).await;
dummy_init_seq(); dummy_init_seq();
} }
async fn insert_trade_at( /// One pool touched by a transaction: the reserves it is left holding, and the
conn: &mut sqlx::pool::PoolConnection<sqlx::Sqlite>, /// deltas that got it there.
token: &TokenID, struct Leg {
txid_byte: u8, pool: u8,
ts: u64, reserves: (u64, i64),
sats_delta: i64, deltas: (i64, 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( fn pool_hash(pool: u8) -> OutPointHash {
&txid, OutPointHash::from_byte_array([pool; 32])
&utxo, }
token,
sats_delta.unsigned_abs(),
token_delta,
&PubkeyHash::all_zeros(),
);
insert_utxo_funding(&mut **conn, &vec![cauldron.clone()], &txid) /// Register a pool so the `pool` join in [`spot::load_snapshot`] can see it.
.await /// Production writes this row via `insert_new_pool` when the pool is summoned.
.unwrap(); async fn create_pool(conn: &mut sqlx::SqliteConnection, pool: u8, token: &TokenID) {
insert_mempool_tx(&mut **conn, &txid, ts).await.unwrap(); sqlx::query(
insert_block_tx(&mut **conn, &txid, &block, ts as i64) "INSERT OR IGNORE INTO pool (creation_utxo, owner_pkh, token_id, withdrawn_in_utxo)
.await VALUES (?, ?, ?, NULL)",
.unwrap();
pool::insert_pool_history_entry(
&mut **conn,
&pool_hash,
&cauldron,
Some(ts),
Some(ts),
sats_delta,
token_delta,
) )
.bind(pool_hash(pool).to_blob())
.bind(PubkeyHash::all_zeros().to_blob())
.bind(token.to_blob())
.execute(&mut *conn)
.await .await
.unwrap(); .unwrap();
} }
/// Insert one transaction that touches several pools, each leg with its own /// Insert one transaction touching every pool in `legs`.
/// `(sats_delta, token_delta)`. Models a router/arbitrage transaction. async fn insert_tx(
async fn insert_multileg_trade_at(
conn: &mut sqlx::pool::PoolConnection<sqlx::Sqlite>, conn: &mut sqlx::pool::PoolConnection<sqlx::Sqlite>,
token: &TokenID, token: &TokenID,
txid_byte: u8, txid_byte: u8,
ts: u64, ts: u64,
legs: &[(i64, i64)], legs: &[Leg],
confirmed: bool,
) { ) {
let txid = Txid::from_byte_array([txid_byte; 32]); let txid = Txid::from_byte_array([txid_byte; 32]);
let block = BlockHash::all_zeros();
insert_mempool_tx(&mut **conn, &txid, ts).await.unwrap(); insert_mempool_tx(&mut **conn, &txid, ts).await.unwrap();
insert_block_tx(&mut **conn, &txid, &block, ts as i64) if confirmed {
.await insert_block_tx(&mut **conn, &txid, &BlockHash::all_zeros(), ts as i64)
.unwrap(); .await
.unwrap();
}
for (i, (sats_delta, token_delta)) in legs.iter().enumerate() { for leg in legs {
create_pool(&mut **conn, leg.pool, token).await;
// The entry's own utxo must be unique per (transaction, pool).
let mut utxo_bytes = [txid_byte; 32]; let mut utxo_bytes = [txid_byte; 32];
utxo_bytes[0] = i as u8; utxo_bytes[0] = leg.pool;
let utxo = OutPointHash::from_byte_array(utxo_bytes); let utxo = OutPointHash::from_byte_array(utxo_bytes);
let mut pool_bytes = [txid_byte.wrapping_add(0x80); 32];
pool_bytes[0] = i as u8;
let pool_hash = OutPointHash::from_byte_array(pool_bytes);
// Post-trade reserves large enough to look like a real pool.
let cauldron = dummy_cauldron( let cauldron = dummy_cauldron(
&txid, &txid,
&utxo, &utxo,
token, token,
sats_delta.unsigned_abs() * 10, leg.reserves.0,
token_delta.abs() * 10, leg.reserves.1,
&PubkeyHash::all_zeros(), &PubkeyHash::all_zeros(),
); );
insert_utxo_funding(&mut **conn, &vec![cauldron.clone()], &txid) insert_utxo_funding(&mut **conn, &vec![cauldron.clone()], &txid)
.await .await
.unwrap(); .unwrap();
pool::insert_pool_history_entry( pool::insert_pool_history_entry(
&mut **conn, &mut **conn,
&pool_hash, &pool_hash(leg.pool),
&cauldron, &cauldron,
Some(ts), Some(ts),
Some(ts), Some(ts),
*sats_delta, leg.deltas.0,
*token_delta, leg.deltas.1,
) )
.await .await
.unwrap(); .unwrap();
} }
} }
/// Regression for the netting artifact: a multi-pool arbitrage transaction that buys /// A single-pool trade: reserves left behind, and the deltas that made them.
/// from one pool and sells into another nets its token movement to almost nothing. fn leg(pool: u8, reserves: (u64, i64), deltas: (i64, i64)) -> Leg {
/// Dividing the signed sums produced a price no leg traded at — on mainnet token NWB Leg {
/// (tx 1E84F4E9…1916, 27 legs) that printed 30,792,599.5 sats/unit against legs that pool,
/// actually executed between 0.288 and 0.335. reserves,
#[tokio::test] deltas,
async fn test_multipool_arb_priced_by_gross_volume_not_net() { }
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xC1; 32]);
let token_blob = token.to_blob();
// Real leg totals from the NWB transaction, collapsed to two legs.
let sell = (-446_491_239i64, 1_334_527_069i64); // executes at 0.334569
let buy = (384_906_040i64, -1_334_527_067i64); // executes at 0.288421
let mut conn = db.cauldron_w.acquire().await.unwrap();
insert_multileg_trade_at(&mut conn, &token, 0x21, 1000, &[sell, buy]).await;
let price = super::fetch_last_close_before(&db.cauldron_r, &token_blob, 2000)
.await
.unwrap()
.expect("arb transaction must price");
let low_leg = 384_906_040.0 / 1_334_527_067.0;
let high_leg = 446_491_239.0 / 1_334_527_069.0;
assert!(
price >= low_leg && price <= high_leg,
"price {price} must lie within the executed leg range [{low_leg}, {high_leg}]"
);
// Net-ratio pricing would divide 61,585,199 sats by 2 token units.
let net_ratio = 61_585_199.0 / 2.0;
assert!(
price < net_ratio / 1000.0,
"price {price} must not resemble the netting artifact {net_ratio}"
);
let expected = 831_397_279.0 / 2_669_054_136.0;
assert!((price - expected).abs() < 1e-9, "expected {expected}");
} }
/// Every leg pointing the same way is the ordinary case: gross and net agree exactly, /// Withdraw `pool` in a transaction at `ts`, the way `flag_as_withdrawn` does.
/// so 99.76% of mainnet prints — including the OLA supply-shock crash — are untouched. async fn withdraw_pool(
#[tokio::test] conn: &mut sqlx::pool::PoolConnection<sqlx::Sqlite>,
async fn test_single_direction_multileg_price_matches_net_ratio() { pool: u8,
let db = mock_db_pool(setup_db).await; txid_byte: u8,
let token = TokenID::from_byte_array([0xC2; 32]); ts: u64,
let token_blob = token.to_blob(); ) {
let txid = Txid::from_byte_array([txid_byte; 32]);
let legs = [(150_000i64, -3_000i64), (50_000, -1_000), (99_000, -2_000)]; insert_mempool_tx(&mut **conn, &txid, ts).await.unwrap();
let mut conn = db.cauldron_w.acquire().await.unwrap(); insert_block_tx(&mut **conn, &txid, &BlockHash::all_zeros(), ts as i64)
insert_multileg_trade_at(&mut conn, &token, 0x22, 1000, &legs).await;
let price = super::fetch_last_close_before(&db.cauldron_r, &token_blob, 2000)
.await .await
.unwrap() .unwrap();
.expect("router transaction must price"); sqlx::query("INSERT OR REPLACE INTO utxo_spending (spent_utxo_hash, txid) VALUES (?, ?)")
.bind(pool_hash(pool).to_blob())
let signed_sats: i64 = legs.iter().map(|l| l.0).sum(); .bind(txid.to_blob())
let signed_tokens: i64 = legs.iter().map(|l| l.1).sum(); .execute(&mut **conn)
let net_ratio = (signed_sats as f64 / signed_tokens as f64).abs(); .await
assert!( .unwrap();
(price - net_ratio).abs() < f64::EPSILON, sqlx::query("UPDATE pool SET withdrawn_in_utxo = ? WHERE creation_utxo = ?")
"single-direction transactions must be unaffected: {price} vs {net_ratio}" .bind(pool_hash(pool).to_blob())
); .bind(pool_hash(pool).to_blob())
.execute(&mut **conn)
.await
.unwrap();
} }
/// A transaction whose legs cancel exactly used to print nothing at all (the chart /// The aggregate pool price a window would open at — what seeds gap-fill.
/// carried the previous close). Its legs are real executions and now price normally. async fn seed_price(conn: &SqlitePool, token: &TokenID, ts: i64) -> Option<f64> {
#[tokio::test]
async fn test_exactly_cancelling_legs_still_price() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xC3; 32]);
let token_blob = token.to_blob(); let token_blob = token.to_blob();
SpotState::new(
spot::load_snapshot(conn, Some(&token_blob), ts, Confirmed::OrMempool)
.await
.unwrap()
.remove(&token_blob)
.unwrap_or_default(),
)
.price()
}
/// The bug this pricing rule exists for.
///
/// Reserves and deltas are mainnet GIRL's (token 63664918…f455), the buy at
/// 2026-08-10 15:45:54 and the sell at 2026-08-11 08:52:47. Priced by execution
/// average those print 0.0784 then 0.0836 — the chart stepping *up* on a sell, in
/// a pool whose price had just fallen from 0.0926 to 0.0760.
#[tokio::test]
async fn test_sell_after_buy_closes_lower() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xC0; 32]);
let mut conn = db.cauldron_w.acquire().await.unwrap(); let mut conn = db.cauldron_w.acquire().await.unwrap();
insert_multileg_trade_at(
insert_tx(
&mut conn, &mut conn,
&token, &token,
0x23, 0x01,
1000, HOUR,
&[(-9_000, 1_000), (11_000, -1_000)], &[leg(
1,
(540_052, 818_802_757_370_920),
(100_000, -185_511_337_091_708),
)],
true,
)
.await;
insert_tx(
&mut conn,
&token,
0x02,
2 * HOUR,
&[leg(
1,
(640_052, 691_199_193_943_404),
(100_000, -127_603_563_427_516),
)],
true,
)
.await;
insert_tx(
&mut conn,
&token,
0x03,
3 * HOUR,
&[leg(
1,
(580_053, 762_931_584_125_945),
(-59_999, 71_732_390_182_541),
)],
true,
) )
.await; .await;
let price = super::fetch_last_close_before(&db.cauldron_r, &token_blob, 2000) let candles = super::candlesticks(
.await &db.cauldron_r,
.unwrap() HOUR as i64,
.expect("zero-net transaction must still price from its legs"); 4 * HOUR as i64,
HOUR as i64,
&token.to_string(),
0,
)
.await
.unwrap();
// 20_000 gross sats over 2_000 gross tokens, between the 9 and 11 leg prices. assert_eq!(candles.len(), 3);
assert!((price - 10.0).abs() < f64::EPSILON, "got {price}"); let buy = candles[1].close;
} let sell = candles[2].close;
/// Legs that move no tokens cannot produce a price (division by zero volume).
#[tokio::test]
async fn test_token_less_legs_do_not_price() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xC4; 32]);
let token_blob = token.to_blob();
let mut conn = db.cauldron_w.acquire().await.unwrap();
insert_multileg_trade_at(&mut conn, &token, 0x24, 1000, &[(5_000, 0), (7_000, 0)]).await;
let price = super::fetch_last_close_before(&db.cauldron_r, &token_blob, 2000)
.await
.unwrap();
assert!(price.is_none(), "no token movement means no price");
}
#[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!( assert!(
(result.unwrap() - 50.0).abs() < f64::EPSILON, (buy * 1e8 - 0.0926).abs() < 1e-4,
"net-zero trade must not affect close price" "buy must close at the price it created, got {}",
buy * 1e8
);
assert!(
(sell * 1e8 - 0.0760).abs() < 1e-4,
"sell must close at the price it created, got {}",
sell * 1e8
);
assert!(
sell < buy,
"a sell closed at {} above the buy before it at {} — the execution-average artifact",
sell * 1e8,
buy * 1e8
); );
} }
/// Trades for a different token must not bleed into results for the queried token. /// A multi-pool arbitrage transaction buys from one pool and sells into another, so
/// its legs nearly cancel. Dividing the signed deltas printed a price no leg traded
/// at — on mainnet token NWB (tx 1E84F4E9…1916, 27 legs) that was 30,792,599.5
/// sats/unit against legs executing between 0.288 and 0.335. Pricing by reserves
/// cannot express the artifact at all: reserves are never negative and never cancel.
#[tokio::test] #[tokio::test]
async fn test_fetch_last_close_before_token_isolation() { async fn test_multipool_arb_prices_at_its_reserves() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xC1; 32]);
let mut conn = db.cauldron_w.acquire().await.unwrap();
// Real leg totals from the NWB transaction, collapsed to two pools.
insert_tx(
&mut conn,
&token,
0x21,
HOUR,
&[
leg(1, (1_000_000, 3_000_000), (-446_491_239, 1_334_527_069)),
leg(2, (2_000_000, 6_000_000), (384_906_040, -1_334_527_067)),
],
true,
)
.await;
let price = seed_price(&db.cauldron_r, &token, 2 * HOUR as i64)
.await
.expect("arb transaction must price");
let expected = 3_000_000.0 / 9_000_000.0;
assert!((price - expected).abs() < 1e-12, "got {price}");
// Net-ratio pricing would divide 61,585,199 sats by 2 token units.
assert!(
price < 61_585_199.0 / 2.0 / 1000.0,
"price {price} must not resemble the netting artifact"
);
}
/// Price comes from reserves, but volume still comes from the gross per-leg sums —
/// an arbitrage transaction moved every satoshi and token its legs moved.
#[tokio::test]
async fn test_multipool_arb_keeps_gross_volume() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xC2; 32]);
let mut conn = db.cauldron_w.acquire().await.unwrap();
insert_tx(
&mut conn,
&token,
0x22,
HOUR,
&[
leg(1, (1_000_000, 3_000_000), (-446_491_239, 1_334_527_069)),
leg(2, (2_000_000, 6_000_000), (384_906_040, -1_334_527_067)),
],
true,
)
.await;
let candles = super::candlesticks(
&db.cauldron_r,
HOUR as i64,
2 * HOUR as i64,
HOUR as i64,
&token.to_string(),
0,
)
.await
.unwrap();
assert_eq!(candles[0].volume_sats, 446_491_239 + 384_906_040);
assert_eq!(candles[0].volume_tokens, 1_334_527_069 + 1_334_527_067);
assert_eq!(candles[0].transaction_count, 1, "one transaction, two legs");
}
/// A withdrawal has no `pool_history_entry` row of its own; if the replay misses it
/// the drained pool's reserves stay in the sum forever.
#[tokio::test]
async fn test_withdrawal_moves_price_without_volume() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xC3; 32]);
let mut conn = db.cauldron_w.acquire().await.unwrap();
insert_tx(
&mut conn,
&token,
0x31,
HOUR,
&[leg(1, (100, 100), (10, -10)), leg(2, (900, 100), (10, -10))],
true,
)
.await;
withdraw_pool(&mut conn, 2, 0x32, 2 * HOUR).await;
let candles = super::candlesticks(
&db.cauldron_r,
HOUR as i64,
4 * HOUR as i64,
HOUR as i64,
&token.to_string(),
0,
)
.await
.unwrap();
assert_eq!(candles[0].close, 5.0, "both pools live");
// recorded a second late, so it lands in the bucket after the withdrawal
assert_eq!(candles[1].close, 1.0, "withdrawn pool must leave the sum");
assert_eq!(candles[1].volume_sats, 0, "a withdrawal is not volume");
assert_eq!(candles[2].close, 1.0, "and must stay out");
}
#[tokio::test]
async fn test_seed_price_without_pools_is_none() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xAA; 32]);
assert!(seed_price(&db.cauldron_r, &token, 2_000_000_000)
.await
.is_none());
}
#[tokio::test]
async fn test_seed_price_ignores_later_trades() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xAB; 32]);
let mut conn = db.cauldron_w.acquire().await.unwrap();
insert_tx(
&mut conn,
&token,
0x01,
2000,
&[leg(1, (100_000, 2_000), (100_000, 2_000))],
true,
)
.await;
assert!(seed_price(&db.cauldron_r, &token, 1000).await.is_none());
}
/// Each pool contributes its latest state before the cutoff, not its first.
#[tokio::test]
async fn test_seed_price_takes_the_latest_state_per_pool() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xAC; 32]);
let mut conn = db.cauldron_w.acquire().await.unwrap();
insert_tx(
&mut conn,
&token,
0x01,
1000,
&[leg(1, (40_000, 2_000), (40_000, 2_000))],
true,
)
.await;
insert_tx(
&mut conn,
&token,
0x02,
2000,
&[leg(1, (100_000, 2_000), (60_000, 0))],
true,
)
.await;
insert_tx(
&mut conn,
&token,
0x03,
4000,
&[leg(1, (200_000, 2_000), (100_000, 0))],
true,
)
.await;
let price = seed_price(&db.cauldron_r, &token, 3000).await.unwrap();
assert!((price - 50.0).abs() < f64::EPSILON, "got {price}");
}
/// An entry exactly at the cutoff belongs to the window, not to its seed.
#[tokio::test]
async fn test_seed_price_excludes_the_boundary() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xAD; 32]);
let mut conn = db.cauldron_w.acquire().await.unwrap();
insert_tx(
&mut conn,
&token,
0x04,
1000,
&[leg(1, (100_000, 2_000), (100_000, 2_000))],
true,
)
.await;
assert!(
seed_price(&db.cauldron_r, &token, 1000).await.is_none(),
"entry at the cutoff must be left to the window"
);
}
/// The price is summed reserves over every live pool, not an average of per-pool
/// prices — a deep pool must dominate a shallow one.
#[tokio::test]
async fn test_seed_price_sums_live_pools() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xAE; 32]);
let mut conn = db.cauldron_w.acquire().await.unwrap();
insert_tx(
&mut conn,
&token,
0x05,
500,
&[
leg(1, (100, 100), (100, 100)),
leg(2, (9_900, 900), (9_900, 900)),
],
true,
)
.await;
let price = seed_price(&db.cauldron_r, &token, 1000).await.unwrap();
// 10_000 sats over 1_000 tokens; the per-pool average would be 6.
assert!((price - 10.0).abs() < f64::EPSILON, "got {price}");
}
#[tokio::test]
async fn test_seed_price_is_token_isolated() {
let db = mock_db_pool(setup_db).await; let db = mock_db_pool(setup_db).await;
let token_a = TokenID::from_byte_array([0xAF; 32]); let token_a = TokenID::from_byte_array([0xAF; 32]);
let token_b = TokenID::from_byte_array([0xBF; 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(); 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) insert_tx(
.await &mut conn,
.unwrap(); &token_b,
assert!(result.is_none(), "other token's trade must not appear"); 0x07,
500,
&[leg(1, (100_000, 2_000), (100_000, 2_000))],
true,
)
.await;
assert!(
seed_price(&db.cauldron_r, &token_a, 1000).await.is_none(),
"another token's pool must not appear"
);
}
/// The live path shows a trade as soon as it is seen; `ohlcv_1h` waits for a block.
#[tokio::test]
async fn test_raw_path_prices_mempool_trades() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xB1; 32]);
let mut conn = db.cauldron_w.acquire().await.unwrap();
insert_tx(
&mut conn,
&token,
0x41,
HOUR,
&[leg(1, (300, 100), (300, 100))],
false,
)
.await;
let candles = super::candlesticks(
&db.cauldron_r,
HOUR as i64,
2 * HOUR as i64,
HOUR as i64,
&token.to_string(),
0,
)
.await
.unwrap();
assert_eq!(candles[0].close, 3.0);
let confirmed = spot::load_events(
&db.cauldron_r,
Some(&token.to_blob()),
HOUR as i64,
2 * HOUR as i64,
Confirmed::Only,
)
.await
.unwrap();
assert!(
confirmed.is_empty(),
"an unconfirmed trade must stay out of the materialised table"
);
}
/// A quiet stretch repeats the last close rather than dropping out of the series,
/// so the same period looks identical at every timeframe.
#[tokio::test]
async fn test_quiet_buckets_carry_the_last_close() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xB2; 32]);
let mut conn = db.cauldron_w.acquire().await.unwrap();
insert_tx(
&mut conn,
&token,
0x51,
HOUR,
&[leg(1, (700, 100), (700, 100))],
true,
)
.await;
let candles = super::candlesticks(
&db.cauldron_r,
HOUR as i64,
5 * HOUR as i64,
HOUR as i64,
&token.to_string(),
0,
)
.await
.unwrap();
assert_eq!(candles.len(), 4);
for candle in &candles {
assert_eq!(candle.close, 7.0);
}
assert_eq!(
candles[3].transaction_count, 0,
"quiet buckets have no trades"
);
} }

View file

@ -21,6 +21,7 @@ pub mod ohlcv;
pub mod pool; pub mod pool;
pub mod poolvisitor; pub mod poolvisitor;
pub mod priceseries; pub mod priceseries;
pub mod spot;
pub mod tokenlist; pub mod tokenlist;
pub mod tokentoken; pub mod tokentoken;
pub mod tx; pub mod tx;

View file

@ -3,7 +3,10 @@
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later. // 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 // A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use std::collections::HashMap;
use crate::db::cauldron::config::{config_get, config_set}; use crate::db::cauldron::config::{config_get, config_set};
use crate::db::cauldron::spot::{self, build_spot_ohlc, Confirmed, SpotState, TokenKey};
use anyhow::Result; use anyhow::Result;
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
@ -12,7 +15,9 @@ use sqlx::{Row, SqlitePool};
/// `INSERT OR IGNORE`, so existing rows are never corrected in place. /// `INSERT OR IGNORE`, so existing rows are never corrected in place.
/// ///
/// 2: price switched from the signed net ratio to the gross volume ratio. /// 2: price switched from the signed net ratio to the gross volume ratio.
pub const OHLCV_VERSION: u32 = 2; /// 3: price switched from the trades' execution average to the reserves they left
/// behind, so a sell can no longer print above the buy before it (see `spot`).
pub const OHLCV_VERSION: u32 = 3;
const OHLCV_VERSION_KEY: &str = "ohlcv_version"; const OHLCV_VERSION_KEY: &str = "ohlcv_version";
pub async fn create_table(pool: &SqlitePool) { pub async fn create_table(pool: &SqlitePool) {
@ -81,11 +86,63 @@ pub async fn get_min_trade_bucket_ts(pool: &SqlitePool) -> Result<Option<i64>> {
Ok(row.and_then(|r| r.0)) Ok(row.and_then(|r| r.0))
} }
/// Materialise all 1-hour OHLCV buckets for confirmed trades whose effective timestamp falls /// Gross traded volume per (token, bucket): `(volume_sats, volume_tokens, tx_count)`.
/// in `[since_ts, until_ts)`.
/// ///
/// Two-phase approach: the slow aggregation SELECT runs against `read_pool` (no write lock), /// Volumes are gross sums of the absolute per-leg deltas. Summing the *signed*
/// then the pre-computed rows are bulk-inserted via `write_pool` (write lock held briefly). /// deltas instead lets a multi-pool arbitrage transaction — which buys from one pool
/// and sells into others — cancel almost all of its token movement and report a
/// fraction of the volume it actually moved.
async fn load_bucket_volumes(
read_pool: &SqlitePool,
since_ts: i64,
until_ts: i64,
) -> Result<HashMap<(TokenKey, i64), (i64, i64, i64)>> {
let sql = r#"
WITH tx_trades AS (
SELECT
phe.token_id AS token_id,
(phe.effective_timestamp / 3600) * 3600 AS bucket_ts,
SUM(ABS(phe.sats_delta)) AS vol_sats,
SUM(ABS(phe.token_delta)) AS vol_tokens
FROM pool_history_entry AS phe
JOIN tx ON tx.txid = phe.txid
WHERE tx.blockhash IS NOT NULL
AND phe.effective_timestamp >= ?
AND phe.effective_timestamp < ?
GROUP BY phe.token_id, phe.txid, phe.effective_timestamp
)
SELECT token_id, bucket_ts, SUM(vol_sats), SUM(vol_tokens), COUNT(*)
FROM tx_trades
GROUP BY token_id, bucket_ts
"#;
let rows = sqlx::query(sql)
.bind(since_ts)
.bind(until_ts)
.fetch_all(read_pool)
.await?;
Ok(rows
.into_iter()
.map(|r| {
let token_id: Vec<u8> = r.get(0);
let bucket_ts: i64 = r.get(1);
((token_id, bucket_ts), (r.get(2), r.get(3), r.get(4)))
})
.collect())
}
/// Materialise all 1-hour OHLCV buckets whose effective timestamp falls in
/// `[since_ts, until_ts)`, for confirmed transactions only.
///
/// Price is the aggregate pool spot price replayed across the range (see [`spot`]),
/// so it tracks what the pools were actually quoting rather than what the trades
/// averaged. Only buckets containing a pool change are stored: a bucket with no
/// events repeats the previous close exactly, and the read path reconstructs it by
/// carrying that close forward.
///
/// Two-phase approach: the reads run against `read_pool` (no write lock), then the
/// pre-computed rows are bulk-inserted via `write_pool` (write lock held briefly).
/// Uses INSERT OR IGNORE so existing rows are never overwritten. /// Uses INSERT OR IGNORE so existing rows are never overwritten.
/// Returns the number of rows inserted. /// Returns the number of rows inserted.
pub async fn rebuild_range( pub async fn rebuild_range(
@ -98,132 +155,77 @@ pub async fn rebuild_range(
return Ok(0); return Ok(0);
} }
// Phase 1: aggregate using the read pool — no write lock held during the slow CTE. // Phase 1: read and replay using the read pool — no write lock held throughout.
let select_sql = r#" let mut events =
WITH per_pool_tx_raw AS ( spot::load_events(read_pool, None, since_ts, until_ts, Confirmed::Only).await?;
SELECT if events.is_empty() {
phe.token_id, return Ok(0);
phe.txid, }
phe.effective_timestamp AS ts, let mut snapshots = spot::load_snapshot(read_pool, None, since_ts, Confirmed::Only).await?;
phe.utxo, let volumes = load_bucket_volumes(read_pool, since_ts, until_ts).await?;
phe.sats_delta,
phe.token_delta,
phe.sequence
FROM pool_history_entry AS phe
JOIN tx ON tx.txid = phe.txid
WHERE tx.blockhash IS NOT NULL
AND phe.effective_timestamp >= ?
AND phe.effective_timestamp < ?
),
per_pool_tx AS (
SELECT
token_id,
txid,
ts,
(ts / 3600) * 3600 AS bucket_ts,
utxo,
MIN(sequence) AS min_sequence,
SUM(ABS(sats_delta)) AS vol_sats,
SUM(ABS(token_delta)) AS vol_tokens
FROM per_pool_tx_raw
GROUP BY token_id, txid, ts, utxo
),
tx_trades AS (
SELECT
token_id,
txid,
ts,
bucket_ts,
MIN(min_sequence) AS min_sequence,
SUM(vol_sats) AS vol_sats,
SUM(vol_tokens) AS vol_tokens
FROM per_pool_tx
GROUP BY token_id, txid, ts
),
priceable AS (
SELECT
token_id,
bucket_ts,
CAST(vol_sats AS REAL) / CAST(vol_tokens AS REAL) AS price,
ROW_NUMBER() OVER (PARTITION BY token_id, bucket_ts ORDER BY ts ASC, min_sequence ASC) AS rn_asc,
ROW_NUMBER() OVER (PARTITION BY token_id, bucket_ts ORDER BY ts DESC, min_sequence DESC) AS rn_desc
FROM tx_trades
WHERE vol_tokens != 0
),
ohlc AS (
SELECT
token_id,
bucket_ts,
MAX(CASE WHEN rn_asc = 1 THEN price END) AS open,
MAX(CASE WHEN rn_desc = 1 THEN price END) AS close,
MAX(price) AS high,
MIN(price) AS low
FROM priceable
GROUP BY token_id, bucket_ts
),
vol AS (
SELECT
token_id,
bucket_ts,
SUM(vol_sats) AS volume_sats,
SUM(vol_tokens) AS volume_tokens,
COUNT(*) AS tx_count
FROM tx_trades
GROUP BY token_id, bucket_ts
)
SELECT
ohlc.token_id,
ohlc.bucket_ts,
ohlc.open,
ohlc.high,
ohlc.low,
ohlc.close,
vol.volume_sats,
vol.volume_tokens,
vol.tx_count
FROM ohlc
JOIN vol ON ohlc.token_id = vol.token_id AND ohlc.bucket_ts = vol.bucket_ts
"#;
let rows = sqlx::query(select_sql) struct Materialised {
.bind(since_ts) token_id: TokenKey,
.bind(until_ts) bucket_ts: i64,
.fetch_all(read_pool) open: f64,
.await?; high: f64,
low: f64,
close: f64,
volume_sats: i64,
volume_tokens: i64,
tx_count: i64,
}
if rows.is_empty() { let mut pending: Vec<Materialised> = Vec::new();
let token_ids: Vec<TokenKey> = events.keys().cloned().collect();
for token_id in token_ids {
let token_events = events.remove(&token_id).unwrap_or_default();
let state = SpotState::new(snapshots.remove(&token_id).unwrap_or_default());
for candle in build_spot_ohlc(state, &token_events, since_ts, until_ts, 3600) {
if !candle.has_event {
continue;
}
let (volume_sats, volume_tokens, tx_count) = volumes
.get(&(token_id.clone(), candle.time))
.copied()
.unwrap_or((0, 0, 0));
pending.push(Materialised {
token_id: token_id.clone(),
bucket_ts: candle.time,
open: candle.open,
high: candle.high,
low: candle.low,
close: candle.close,
volume_sats,
volume_tokens,
tx_count,
});
}
}
if pending.is_empty() {
return Ok(0); return Ok(0);
} }
// Phase 2: insert pre-computed rows inside a single transaction. // Phase 2: insert pre-computed rows inside a single transaction.
// The write lock is held only for these fast INSERTs, not during aggregation. // The write lock is held only for these fast INSERTs, not during the replay.
let mut tx = write_pool.begin().await?; let mut tx = write_pool.begin().await?;
let mut inserted = 0u64; let mut inserted = 0u64;
for row in &rows { for row in pending {
let token_id: Vec<u8> = row.get(0);
let bucket_ts: i64 = row.get(1);
let open: f64 = row.get(2);
let high: f64 = row.get(3);
let low: f64 = row.get(4);
let close: f64 = row.get(5);
let volume_sats: i64 = row.get(6);
let volume_tokens: i64 = row.get(7);
let tx_count: i64 = row.get(8);
inserted += sqlx::query( inserted += sqlx::query(
"INSERT OR IGNORE INTO ohlcv_1h "INSERT OR IGNORE INTO ohlcv_1h
(token_id, bucket_ts, open, high, low, close, volume_sats, volume_tokens, tx_count) (token_id, bucket_ts, open, high, low, close, volume_sats, volume_tokens, tx_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
) )
.bind(token_id) .bind(row.token_id)
.bind(bucket_ts) .bind(row.bucket_ts)
.bind(open) .bind(row.open)
.bind(high) .bind(row.high)
.bind(low) .bind(row.low)
.bind(close) .bind(row.close)
.bind(volume_sats) .bind(row.volume_sats)
.bind(volume_tokens) .bind(row.volume_tokens)
.bind(tx_count) .bind(row.tx_count)
.execute(&mut *tx) .execute(&mut *tx)
.await? .await?
.rows_affected(); .rows_affected();
@ -282,12 +284,14 @@ pub async fn get_active_candles(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::db::cauldron::{pool as cauldron_pool, tx, utxo_funding}; use crate::db::cauldron::{pool as cauldron_pool, tx, utxo_funding, utxo_spending};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
static OHLCV_TEST_COUNTER: AtomicU64 = AtomicU64::new(0); static OHLCV_TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
const HOUR: i64 = 3600;
async fn test_pool() -> SqlitePool { async fn test_pool() -> SqlitePool {
let id = OHLCV_TEST_COUNTER.fetch_add(1, Ordering::SeqCst); let id = OHLCV_TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let uri = format!("file:ohlcv_test_{}?mode=memory&cache=shared", id); let uri = format!("file:ohlcv_test_{}?mode=memory&cache=shared", id);
@ -300,108 +304,155 @@ mod tests {
async fn setup_db(pool: &SqlitePool) { async fn setup_db(pool: &SqlitePool) {
tx::create_table(pool).await; tx::create_table(pool).await;
utxo_funding::create_table(pool).await; utxo_funding::create_table(pool).await;
utxo_spending::create_table(pool).await;
cauldron_pool::create_table(pool).await; cauldron_pool::create_table(pool).await;
create_table(pool).await; // ohlcv_1h + idx_phe_txid create_table(pool).await; // ohlcv_1h + idx_phe_txid
} }
/// Insert a single confirmed trade via raw SQL (FK disabled in tests). /// One trade leg, inserted via raw SQL (FK disabled in tests).
async fn insert_confirmed_trade( ///
pool: &SqlitePool, /// `reserves` is the state the leg leaves its pool holding — what the price is
/// now read from — and `deltas` what it moved, which is what volume is read from.
#[allow(clippy::too_many_arguments)]
async fn insert_leg(
conn: &SqlitePool,
txid: [u8; 32], txid: [u8; 32],
utxo: [u8; 32], utxo: [u8; 32],
pool_hash: [u8; 32],
token_id: [u8; 32], token_id: [u8; 32],
mtp_ts: i64, ts: i64,
sats_delta: i64, confirmed: bool,
token_delta: i64, reserves: (i64, i64),
deltas: (i64, i64),
) { ) {
let blockhash = [0xAA_u8; 32]; if confirmed {
sqlx::query("INSERT OR IGNORE INTO tx (txid, blockhash, mtp_timestamp) VALUES (?, ?, ?)") sqlx::query(
"INSERT OR IGNORE INTO tx (txid, blockhash, mtp_timestamp) VALUES (?, ?, ?)",
)
.bind(txid.as_slice()) .bind(txid.as_slice())
.bind(blockhash.as_slice()) .bind([0xAA_u8; 32].as_slice())
.bind(mtp_ts) .bind(ts)
.execute(pool) .execute(conn)
.await .await
.unwrap(); .unwrap();
} else {
sqlx::query("INSERT OR IGNORE INTO tx (txid, first_seen_timestamp) VALUES (?, ?)")
.bind(txid.as_slice())
.bind(ts)
.execute(conn)
.await
.unwrap();
}
sqlx::query(
"INSERT OR IGNORE INTO pool (creation_utxo, owner_pkh, token_id, withdrawn_in_utxo)
VALUES (?, ?, ?, NULL)",
)
.bind(pool_hash.as_slice())
.bind([0u8; 20].as_slice())
.bind(token_id.as_slice())
.execute(conn)
.await
.unwrap();
sqlx::query( sqlx::query(
"INSERT INTO utxo_funding (new_utxo_hash, txid, spent_utxo_hash, new_utxo_txid, new_utxo_n, sats, token_amount, token_id) "INSERT INTO utxo_funding (new_utxo_hash, txid, spent_utxo_hash, new_utxo_txid, new_utxo_n, sats, token_amount, token_id)
VALUES (?, ?, ?, ?, 0, 1000, 1000, ?)", VALUES (?, ?, ?, ?, 0, ?, ?, ?)",
) )
.bind(utxo.as_slice()) .bind(utxo.as_slice())
.bind(txid.as_slice()) .bind(txid.as_slice())
.bind([0u8; 32].as_slice()) .bind([0u8; 32].as_slice())
.bind(txid.as_slice()) .bind(txid.as_slice())
.bind(reserves.0)
.bind(reserves.1)
.bind(token_id.as_slice()) .bind(token_id.as_slice())
.execute(pool) .execute(conn)
.await .await
.unwrap(); .unwrap();
let seq: i64 = let seq: i64 =
sqlx::query_scalar("SELECT IFNULL(MAX(sequence), 0) + 1 FROM pool_history_entry") sqlx::query_scalar("SELECT IFNULL(MAX(sequence), 0) + 1 FROM pool_history_entry")
.fetch_one(pool) .fetch_one(conn)
.await .await
.unwrap(); .unwrap();
sqlx::query( let ts_column = if confirmed {
"mtp_timestamp"
} else {
"first_seen_timestamp"
};
sqlx::query(&format!(
"INSERT INTO pool_history_entry "INSERT INTO pool_history_entry
(utxo, pool, token_id, txid, tx_pos, mtp_timestamp, sequence, sats, token_amount, sats_delta, token_delta) (utxo, pool, token_id, txid, tx_pos, {ts_column}, sequence, sats, token_amount, sats_delta, token_delta)
VALUES (?, ?, ?, ?, 0, ?, ?, 1000, 1000, ?, ?)", VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)"
) ))
.bind(utxo.as_slice()) .bind(utxo.as_slice())
.bind([0xBB_u8; 32].as_slice()) // dummy pool hash (FK disabled) .bind(pool_hash.as_slice())
.bind(token_id.as_slice()) .bind(token_id.as_slice())
.bind(txid.as_slice()) .bind(txid.as_slice())
.bind(mtp_ts) .bind(ts)
.bind(seq) .bind(seq)
.bind(sats_delta) .bind(reserves.0)
.bind(token_delta) .bind(reserves.1)
.execute(pool) .bind(deltas.0)
.bind(deltas.1)
.execute(conn)
.await .await
.unwrap(); .unwrap();
} }
/// Insert a mempool-only trade (no blockhash on the tx row). /// A confirmed single-pool trade whose reserves are irrelevant to the assertion.
async fn insert_mempool_trade( async fn insert_confirmed_trade(
pool: &SqlitePool, conn: &SqlitePool,
txid: [u8; 32], txid: [u8; 32],
utxo: [u8; 32], utxo: [u8; 32],
token_id: [u8; 32], token_id: [u8; 32],
first_seen_ts: i64, ts: i64,
sats_delta: i64,
token_delta: i64,
) { ) {
sqlx::query("INSERT INTO tx (txid, first_seen_timestamp) VALUES (?, ?)") insert_leg(
.bind(txid.as_slice()) conn,
.bind(first_seen_ts) txid,
.execute(pool) utxo,
.await [0xBB; 32],
.unwrap(); token_id,
sqlx::query( ts,
"INSERT INTO utxo_funding (new_utxo_hash, txid, spent_utxo_hash, new_utxo_txid, new_utxo_n, sats, token_amount, token_id) true,
VALUES (?, ?, ?, ?, 0, 1000, 1000, ?)", (1000, 1000),
(sats_delta, token_delta),
) )
.bind(utxo.as_slice()) .await;
.bind(txid.as_slice()) }
.bind([0u8; 32].as_slice())
.bind(txid.as_slice()) async fn insert_mempool_trade(
.bind(token_id.as_slice()) conn: &SqlitePool,
.execute(pool) txid: [u8; 32],
.await utxo: [u8; 32],
.unwrap(); token_id: [u8; 32],
let seq: i64 = ts: i64,
sqlx::query_scalar("SELECT IFNULL(MAX(sequence), 0) + 1 FROM pool_history_entry") ) {
.fetch_one(pool) insert_leg(
.await conn,
.unwrap(); txid,
sqlx::query( utxo,
"INSERT INTO pool_history_entry [0xBB; 32],
(utxo, pool, token_id, txid, tx_pos, first_seen_timestamp, sequence, sats, token_amount, sats_delta, token_delta) token_id,
VALUES (?, ?, ?, ?, 0, ?, ?, 1000, 1000, -1000, 25)", ts,
false,
(1000, 1000),
(-1000, 25),
)
.await;
}
async fn closes(conn: &SqlitePool, token_id: [u8; 32]) -> Vec<(i64, f64)> {
sqlx::query_as(
"SELECT bucket_ts, close FROM ohlcv_1h WHERE token_id = ? ORDER BY bucket_ts",
) )
.bind(utxo.as_slice())
.bind([0xBB_u8; 32].as_slice())
.bind(token_id.as_slice()) .bind(token_id.as_slice())
.bind(txid.as_slice()) .fetch_all(conn)
.bind(first_seen_ts)
.bind(seq)
.execute(pool)
.await .await
.unwrap(); .unwrap()
} }
/// `get_min_trade_bucket_ts` should floor a mid-hour timestamp to the hour boundary. /// `get_min_trade_bucket_ts` should floor a mid-hour timestamp to the hour boundary.
@ -476,51 +527,168 @@ mod tests {
assert_eq!(n, 0, "mempool trades must not be materialised"); assert_eq!(n, 0, "mempool trades must not be materialised");
} }
/// A multi-pool arbitrage transaction whose legs nearly cancel must materialise the /// The reason for `OHLCV_VERSION` 3.
/// price its legs executed at, not the signed-net ratio. ///
/// Reserves and deltas are mainnet GIRL's (token 63664918…f455) buy at
/// 2026-08-10 15:45:54 followed by its sell at 2026-08-11 08:52:47. Priced by
/// what the trades averaged, the sell materialises *above* the buy — 0.0836
/// against 0.0784 per token — while the pool it traded against had just fallen
/// from 0.0926 to 0.0760.
#[tokio::test] #[tokio::test]
async fn test_rebuild_range_prices_arb_by_gross_volume() { async fn test_rebuild_range_prices_by_reserves_not_execution_average() {
let pool = test_pool().await; let pool = test_pool().await;
setup_db(&pool).await; setup_db(&pool).await;
let token = [0x03_u8; 32];
insert_leg(
&pool,
[0x01; 32],
[0x11; 32],
[0xB1; 32],
token,
HOUR,
true,
(540_052, 818_802_757_370_920),
(100_000, -185_511_337_091_708),
)
.await;
insert_leg(
&pool,
[0x02; 32],
[0x12; 32],
[0xB1; 32],
token,
2 * HOUR,
true,
(640_052, 691_199_193_943_404),
(100_000, -127_603_563_427_516),
)
.await;
insert_leg(
&pool,
[0x03; 32],
[0x13; 32],
[0xB1; 32],
token,
3 * HOUR,
true,
(580_053, 762_931_584_125_945),
(-59_999, 71_732_390_182_541),
)
.await;
rebuild_range(&pool, &pool, 0, 4 * HOUR).await.unwrap();
let rows = closes(&pool, token).await;
assert_eq!(rows.len(), 3);
let buy = rows[1].1;
let sell = rows[2].1;
assert!(
(buy * 1e8 - 0.0926).abs() < 1e-4,
"buy must materialise at the price it created, got {}",
buy * 1e8
);
assert!(
(sell * 1e8 - 0.0760).abs() < 1e-4,
"sell must materialise at the price it created, got {}",
sell * 1e8
);
assert!(
sell < buy,
"a sell materialised at {} above the buy before it at {}",
sell * 1e8,
buy * 1e8
);
}
/// A multi-pool arbitrage transaction prices at the reserves its legs left, and
/// still reports every satoshi and token those legs moved.
#[tokio::test]
async fn test_rebuild_range_arb_prices_by_reserves_and_keeps_gross_volume() {
let pool = test_pool().await;
setup_db(&pool).await;
let token = [0x03_u8; 32]; let token = [0x03_u8; 32];
let txid = [0x01_u8; 32]; let txid = [0x01_u8; 32];
// Same transaction, two pools, opposite directions netting to +2 token units. // Same transaction, two pools, opposite directions netting to +2 token units.
insert_confirmed_trade( insert_leg(
&pool, &pool,
txid, txid,
[0x02; 32], [0x02; 32],
[0xB1; 32],
token, token,
1727963400, HOUR,
-446_491_239, true,
1_334_527_069, (1_000_000, 3_000_000),
(-446_491_239, 1_334_527_069),
) )
.await; .await;
insert_confirmed_trade( insert_leg(
&pool, &pool,
txid, txid,
[0x04; 32], [0x04; 32],
[0xB2; 32],
token, token,
1727963400, HOUR,
384_906_040, true,
-1_334_527_067, (2_000_000, 6_000_000),
(384_906_040, -1_334_527_067),
) )
.await; .await;
rebuild_range(&pool, &pool, 1727960400, 1727964000) rebuild_range(&pool, &pool, 0, 2 * HOUR).await.unwrap();
.await
.unwrap();
let close: f64 = sqlx::query_scalar("SELECT close FROM ohlcv_1h WHERE token_id = ?") let (close, volume_sats, volume_tokens, tx_count): (f64, i64, i64, i64) = sqlx::query_as(
.bind(token.as_slice()) "SELECT close, volume_sats, volume_tokens, tx_count FROM ohlcv_1h WHERE token_id = ?",
.fetch_one(&pool) )
.await .bind(token.as_slice())
.unwrap(); .fetch_one(&pool)
.await
.unwrap();
let expected = 831_397_279.0 / 2_669_054_136.0;
assert!( assert!(
(close - expected).abs() < 1e-9, (close - 3_000_000.0 / 9_000_000.0).abs() < 1e-12,
"materialised close {close} should be the gross ratio {expected}" "close {close} must be the summed reserves the legs left"
);
// Net-ratio pricing would divide 61,585,199 sats by 2 token units.
assert!(
close < 61_585_199.0 / 2.0 / 1000.0,
"netting artifact: {close}"
);
assert_eq!(volume_sats, 446_491_239 + 384_906_040);
assert_eq!(volume_tokens, 1_334_527_069 + 1_334_527_067);
assert_eq!(tx_count, 1, "one transaction, two legs");
}
/// A bucket with no pool change repeats the previous close exactly, so it is
/// reconstructed on read rather than stored — otherwise every token would need a
/// row for every hour it has ever existed.
#[tokio::test]
async fn test_rebuild_range_stores_only_buckets_with_events() {
let pool = test_pool().await;
setup_db(&pool).await;
let token = [0x03_u8; 32];
insert_leg(
&pool,
[0x01; 32],
[0x11; 32],
[0xB1; 32],
token,
HOUR,
true,
(700, 100),
(700, 100),
)
.await;
rebuild_range(&pool, &pool, 0, 10 * HOUR).await.unwrap();
let rows = closes(&pool, token).await;
assert_eq!(
rows,
vec![(HOUR, 7.0)],
"only the bucket that moved is stored"
); );
} }

513
src/db/cauldron/spot.rs Normal file
View file

@ -0,0 +1,513 @@
// 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
//! Aggregate pool spot price, replayed over a time range.
//!
//! A trade's *execution* price — the satoshis it moved divided by the tokens it
//! moved — is the volume-weighted average along the bonding curve, so it always
//! lands strictly between the pool's price before the trade and its price after.
//! A buy therefore prints below the price it created, a sell prints above it, and
//! two consecutive execution prices need not move in the same direction the pool
//! did: a modest sell after a large buy prints *up*, because the sell's average
//! (taken over the range it walked down) still sits above the buy's average
//! (taken over the range it walked up).
//!
//! Pricing by the reserves the trades left behind removes that whole class of
//! artifact — the price is a function of state, so it moves down exactly when the
//! pool moved down — and it makes candles agree with `/price` and the token list,
//! which already quote summed reserves over every live pool.
use std::collections::HashMap;
use anyhow::Result;
use sqlx::{Row, SqlitePool};
/// Reserves left by a pool's latest entry: (sats, token base units).
pub type Reserves = (i64, i64);
/// A pool's identity — its `creation_utxo` / `pool_history_entry.pool` blob.
pub type PoolKey = Vec<u8>;
/// A token's identity — its `token_id` blob, stored byte-reversed.
pub type TokenKey = Vec<u8>;
/// A change to one pool's contribution to its token's price.
///
/// `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, Debug)]
pub struct SpotEvent {
pub ts: i64,
pub sequence: i64,
pub pool: PoolKey,
pub reserves: Option<Reserves>,
}
/// Restricts a load to confirmed transactions.
///
/// `ohlcv_1h` is written once and never corrected, so it must not bake in a
/// mempool transaction that may never confirm; the live path has no such
/// constraint and shows unconfirmed trades as soon as they are seen.
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum Confirmed {
Only,
OrMempool,
}
impl Confirmed {
/// Predicate over a `pool_history_entry` alias' `txid`, or an empty string.
fn phe_clause(self, alias: &str) -> String {
match self {
Confirmed::Only => format!(
"AND EXISTS (SELECT 1 FROM tx WHERE tx.txid = {alias}.txid AND tx.blockhash IS NOT NULL)"
),
Confirmed::OrMempool => String::new(),
}
}
/// Predicate over an already-joined `tx` alias, or an empty string.
fn tx_clause(self, alias: &str) -> &'static str {
match self {
Confirmed::Only => {
debug_assert_eq!(alias, "t");
"AND t.blockhash IS NOT NULL"
}
Confirmed::OrMempool => "",
}
}
}
/// Running summed-reserve price for one token's live pools.
///
/// The sums are maintained incrementally because a busy token can have hundreds of
/// live pools and thousands of events in a window; re-summing the map per event
/// would make the replay quadratic in pool count.
pub struct SpotState {
reserves: HashMap<PoolKey, Reserves>,
sats: i128,
tokens: i128,
}
impl SpotState {
pub fn new(reserves: HashMap<PoolKey, Reserves>) -> Self {
let mut sats: i128 = 0;
let mut tokens: i128 = 0;
for (s, t) in reserves.values() {
sats += *s as i128;
tokens += *t as i128;
}
Self {
reserves,
sats,
tokens,
}
}
/// Price per smallest token unit, in satoshis.
///
/// `None` when the token has no priceable reserves — the same "no price"
/// `/price` reports rather than quoting zero.
pub fn price(&self) -> Option<f64> {
if self.sats <= 0 || self.tokens <= 0 {
return None;
}
let price = self.sats as f64 / self.tokens as f64;
price.is_finite().then_some(price)
}
pub fn apply(&mut self, event: &SpotEvent) {
if let Some((sats, tokens)) = self.reserves.remove(&event.pool) {
self.sats -= sats as i128;
self.tokens -= tokens as i128;
}
if let Some((sats, tokens)) = event.reserves {
self.reserves.insert(event.pool.clone(), (sats, tokens));
self.sats += sats as i128;
self.tokens += tokens as i128;
}
}
}
/// Open/high/low/close of the aggregate spot price across one bucket.
pub struct SpotOhlc {
pub time: i64,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
/// Whether any pool changed inside this bucket. Buckets without one repeat the
/// previous close exactly, so they can be reconstructed instead of stored.
pub has_event: bool,
}
/// Walk `[start, end)` in `step` buckets, replaying `events` over `state`.
///
/// `events` must be sorted by `(ts, sequence)` and hold nothing before `start`.
/// A bucket opens at the price it inherits, so consecutive candles never gap, and
/// closes at the price left by its last event; a bucket with no events is a flat
/// candle at the carried price, which is what the pool price genuinely did. Leading
/// buckets from before the token had any reserves are skipped rather than reported
/// as zero.
pub fn build_spot_ohlc(
mut state: SpotState,
events: &[SpotEvent],
start: i64,
end: i64,
step: i64,
) -> Vec<SpotOhlc> {
let mut out = Vec::new();
let mut next = 0usize;
let mut bucket = start;
while bucket < end {
let bucket_end = bucket.saturating_add(step);
let mut open = state.price();
let mut high = open;
let mut low = open;
let mut close = open;
let mut has_event = false;
while next < events.len() && events[next].ts < bucket_end {
state.apply(&events[next]);
next += 1;
has_event = true;
if let Some(price) = state.price() {
// A token whose first pool is created mid-bucket opens there.
open.get_or_insert(price);
high = Some(high.map_or(price, |h| h.max(price)));
low = Some(low.map_or(price, |l| l.min(price)));
close = Some(price);
}
}
if let (Some(open), Some(high), Some(low), Some(close)) = (open, high, low, close) {
out.push(SpotOhlc {
time: bucket,
open,
high,
low,
close,
has_event,
});
}
bucket = bucket_end;
}
out
}
/// Per-pool reserves, grouped by token, as of just before `ts`.
///
/// "Just before" is deliberate: entries are taken with `effective_timestamp < ts`
/// and a pool whose withdrawal lands at or after `ts` is kept, so a caller can
/// replay [`load_events`] from `ts` on top without applying anything twice. The
/// liveness rules otherwise match `db_visit_pool_entries`, which is what makes the
/// replayed price equal the one `/price` reports.
pub async fn load_snapshot(
conn: &SqlitePool,
token: Option<&[u8]>,
ts: i64,
confirmed: Confirmed,
) -> Result<HashMap<TokenKey, HashMap<PoolKey, Reserves>>> {
let token_filter = if token.is_some() {
"AND p.token_id = ?"
} else {
""
};
let confirmed_filter = confirmed.phe_clause("inner_phe");
// Correlated subquery rather than a window function over the whole table: this
// is one index seek per pool, and the backfill runs it once per batch.
let sql = format!(
"SELECT p.token_id, p.creation_utxo, phe.sats, phe.token_amount
FROM pool p
JOIN pool_history_entry phe ON p.creation_utxo = phe.pool
AND phe.sequence = (
SELECT MAX(inner_phe.sequence) FROM pool_history_entry AS inner_phe
WHERE inner_phe.pool = p.creation_utxo
AND inner_phe.effective_timestamp < ?
{confirmed_filter}
)
WHERE (p.withdrawn_in_utxo IS NULL OR (
SELECT t.effective_timestamp
FROM utxo_spending us JOIN tx t ON us.txid = t.txid
WHERE us.spent_utxo_hash = p.withdrawn_in_utxo
) >= ?)
{token_filter}"
);
let mut query = sqlx::query(&sql).bind(ts).bind(ts);
if let Some(token) = token {
query = query.bind(token.to_vec());
}
let rows = query.fetch_all(conn).await?;
let mut out: HashMap<TokenKey, HashMap<PoolKey, Reserves>> = HashMap::new();
for row in rows {
let token_id: Vec<u8> = row.get(0);
let pool: Vec<u8> = row.get(1);
let sats: i64 = row.get(2);
let tokens: i64 = row.get(3);
out.entry(token_id)
.or_default()
.insert(pool, (sats.max(0), tokens.max(0)));
}
Ok(out)
}
/// Every pool state change in `[start, end)`, grouped by token and sorted.
pub async fn load_events(
conn: &SqlitePool,
token: Option<&[u8]>,
start: i64,
end: i64,
confirmed: Confirmed,
) -> Result<HashMap<TokenKey, Vec<SpotEvent>>> {
let mut by_token: HashMap<TokenKey, Vec<SpotEvent>> = HashMap::new();
let entry_token_filter = if token.is_some() {
"AND phe.token_id = ?"
} else {
""
};
let entries_sql = format!(
"SELECT phe.token_id, phe.pool, phe.sats, phe.token_amount,
phe.effective_timestamp, phe.sequence
FROM pool_history_entry AS phe
WHERE phe.effective_timestamp >= ? AND phe.effective_timestamp < ?
{entry_token_filter}
{}",
confirmed.phe_clause("phe")
);
let mut query = sqlx::query(&entries_sql).bind(start).bind(end);
if let Some(token) = token {
query = query.bind(token.to_vec());
}
for row in query.fetch_all(conn).await? {
let token_id: Vec<u8> = row.get(0);
let sats: i64 = row.get(2);
let tokens: i64 = row.get(3);
by_token.entry(token_id).or_default().push(SpotEvent {
ts: row.get(4),
sequence: row.get(5),
pool: row.get(1),
reserves: Some((sats.max(0), tokens.max(0))),
});
}
// A withdrawal that lands exactly on `start - 1` becomes an event at `start`,
// because `load_snapshot` still counts a pool whose withdrawal is at or after
// its bound — so the pool has to be dropped by the replay rather than by the
// snapshot. Recording every withdrawal a second late lets both kinds of event
// share one comparison.
let withdrawal_token_filter = if token.is_some() {
"AND p.token_id = ?"
} else {
""
};
let withdrawals_sql = format!(
"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 >= ? AND t.effective_timestamp < ?
{withdrawal_token_filter}
{}",
confirmed.tx_clause("t")
);
let mut query = sqlx::query(&withdrawals_sql)
.bind(start.saturating_sub(1))
.bind(end);
if let Some(token) = token {
query = query.bind(token.to_vec());
}
for row in query.fetch_all(conn).await? {
let token_id: Vec<u8> = row.get(0);
let withdrawn_ts: i64 = row.get(2);
by_token.entry(token_id).or_default().push(SpotEvent {
ts: withdrawn_ts.saturating_add(1),
// after every entry sharing the timestamp
sequence: i64::MAX,
pool: row.get(1),
reserves: None,
});
}
for events in by_token.values_mut() {
events.sort_by_key(|e| (e.ts, e.sequence));
}
Ok(by_token)
}
#[cfg(test)]
mod tests {
use super::*;
const HOUR: i64 = 3600;
fn pool_key(n: u8) -> PoolKey {
vec![n; 32]
}
fn state(pairs: &[(u8, i64, i64)]) -> SpotState {
SpotState::new(
pairs
.iter()
.map(|(p, s, t)| (pool_key(*p), (*s, *t)))
.collect(),
)
}
fn entry(ts: i64, pool: u8, sats: i64, tokens: i64) -> SpotEvent {
SpotEvent {
ts,
sequence: ts,
pool: pool_key(pool),
reserves: Some((sats, tokens)),
}
}
fn withdrawal(ts: i64, pool: u8) -> SpotEvent {
SpotEvent {
ts: ts + 1,
sequence: i64::MAX,
pool: pool_key(pool),
reserves: None,
}
}
/// The bug this module exists for: a sell must never raise the price.
///
/// Reserves are GIRL's own, from the trades at 2026-08-10 15:45 and
/// 2026-08-11 08:52. Priced by execution average the pair prints 0.0784 then
/// 0.0836 — up, on a sell. Priced by reserves it prints the move the pool
/// actually made.
#[test]
fn a_sell_lowers_the_close() {
let candles = build_spot_ohlc(
state(&[(1, 540_052, 818_802_757_370_920)]),
&[
entry(0, 1, 640_052, 691_199_193_943_404),
entry(HOUR, 1, 580_053, 762_931_584_125_945),
],
0,
2 * HOUR,
HOUR,
);
let buy_close = candles[0].close;
let sell_close = candles[1].close;
assert!(
(buy_close * 1e8 - 0.0926).abs() < 1e-4,
"buy should close at the pool price it created, got {}",
buy_close * 1e8
);
assert!(
sell_close < buy_close,
"sell closed at {} after a buy closed at {} — a sell must not raise the price",
sell_close * 1e8,
buy_close * 1e8
);
assert!((sell_close * 1e8 - 0.0760).abs() < 1e-4);
}
#[test]
fn a_bucket_opens_where_the_last_one_closed() {
let candles = build_spot_ohlc(
state(&[(1, 100, 100)]),
&[entry(10, 1, 400, 100), entry(HOUR + 10, 1, 200, 100)],
0,
2 * HOUR,
HOUR,
);
assert_eq!(candles[0].open, 1.0);
assert_eq!(candles[0].close, 4.0);
// no gap: the second bucket starts from the first one's close
assert_eq!(candles[1].open, 4.0);
assert_eq!(candles[1].close, 2.0);
assert_eq!(candles[1].high, 4.0);
assert_eq!(candles[1].low, 2.0);
}
#[test]
fn high_and_low_span_the_whole_bucket() {
let candles = build_spot_ohlc(
state(&[(1, 100, 100)]),
&[entry(10, 1, 500, 100), entry(20, 1, 50, 100)],
0,
HOUR,
HOUR,
);
assert_eq!(candles[0].open, 1.0);
assert_eq!(candles[0].high, 5.0);
assert_eq!(candles[0].low, 0.5);
assert_eq!(candles[0].close, 0.5);
}
#[test]
fn a_quiet_bucket_is_flat_at_the_carried_price() {
let candles = build_spot_ohlc(state(&[(1, 300, 100)]), &[], 0, 3 * HOUR, HOUR);
assert_eq!(candles.len(), 3);
for candle in &candles {
assert_eq!(
(candle.open, candle.high, candle.low, candle.close),
(3.0, 3.0, 3.0, 3.0)
);
assert!(!candle.has_event, "a quiet bucket need not be stored");
}
}
#[test]
fn the_price_sums_every_live_pool() {
// aggregate of summed reserves, not an average of per-pool prices
let candles = build_spot_ohlc(state(&[(1, 100, 100), (2, 900, 100)]), &[], 0, HOUR, HOUR);
assert_eq!(candles[0].close, 5.0);
}
#[test]
fn a_withdrawal_moves_the_price_without_volume() {
let candles = build_spot_ohlc(
state(&[(1, 100, 100), (2, 900, 100)]),
&[withdrawal(HOUR, 2)],
0,
3 * HOUR,
HOUR,
);
assert_eq!(candles[0].close, 5.0);
// withdrawal recorded a second late, so it lands in the second bucket
assert_eq!(candles[1].close, 1.0);
assert!(candles[1].has_event, "a withdrawal must be materialised");
assert_eq!(candles[2].close, 1.0);
}
#[test]
fn buckets_before_the_first_pool_are_skipped() {
let candles = build_spot_ohlc(
SpotState::new(HashMap::new()),
&[entry(2 * HOUR, 1, 100, 100)],
0,
4 * HOUR,
HOUR,
);
assert_eq!(candles.len(), 2, "no price before the token had a pool");
assert_eq!(candles[0].time, 2 * HOUR);
assert_eq!(candles[0].open, 1.0);
}
#[test]
fn a_token_with_no_reserves_has_no_candles() {
let candles = build_spot_ohlc(SpotState::new(HashMap::new()), &[], 0, 3 * HOUR, HOUR);
assert!(candles.is_empty());
}
#[test]
fn an_empty_pool_is_not_priced_as_zero() {
// a pool drained to zero tokens has no price rather than an infinite one
let candles = build_spot_ohlc(state(&[(1, 100, 0)]), &[], 0, HOUR, HOUR);
assert!(candles.is_empty());
}
}

View file

@ -8,6 +8,7 @@ use crate::db::cauldron::{
pool::{self, dummy_init_seq, insert_new_pool}, pool::{self, dummy_init_seq, insert_new_pool},
tx::{self, insert_block_tx, insert_mempool_tx}, tx::{self, insert_block_tx, insert_mempool_tx},
utxo_funding::{self, insert_utxo_funding}, utxo_funding::{self, insert_utxo_funding},
utxo_spending,
}; };
use crate::utiltest::mock_db_pool; use crate::utiltest::mock_db_pool;
use crate::OhlcvState; use crate::OhlcvState;
@ -64,6 +65,7 @@ fn dummy_cauldron(
async fn setup_mock_db(pool: sqlx::SqlitePool) { async fn setup_mock_db(pool: sqlx::SqlitePool) {
utxo_funding::create_table(&pool).await; utxo_funding::create_table(&pool).await;
utxo_spending::create_table(&pool).await;
tx::create_table(&pool).await; tx::create_table(&pool).await;
pool::create_table(&pool).await; pool::create_table(&pool).await;
dummy_init_seq(); dummy_init_seq();
@ -168,66 +170,68 @@ async fn setup_mock_db(pool: sqlx::SqlitePool) {
.await .await
.unwrap(); .unwrap();
let token1 = TokenID::from_byte_array([0xda; 32]); // Registered under the same token as their history entries: pricing reads the
// pool's reserves through this row, so a pool filed under another token would
// silently drop out of its own token's price.
let pkh1 = PubkeyHash::from_byte_array([0xca; 20]); let pkh1 = PubkeyHash::from_byte_array([0xca; 20]);
insert_new_pool( for pool_hash in [&pool1, &pool2, &pool3, &pool4] {
&mut conn, insert_new_pool(
&dummy_cauldron(&Txid::all_zeros(), &pool1, &token1, 0, 0, &pkh1), &mut conn,
) &dummy_cauldron(&Txid::all_zeros(), pool_hash, &token_zero, 0, 0, &pkh1),
.await )
.unwrap(); .await
insert_new_pool( .unwrap();
&mut conn, }
&dummy_cauldron(&Txid::all_zeros(), &pool2, &token1, 0, 0, &pkh1),
)
.await
.unwrap();
insert_new_pool(
&mut conn,
&dummy_cauldron(&Txid::all_zeros(), &pool3, &token1, 0, 0, &pkh1),
)
.await
.unwrap();
insert_new_pool(
&mut conn,
&dummy_cauldron(&Txid::all_zeros(), &pool4, &token1, 0, 0, &pkh1),
)
.await
.unwrap();
} }
// ── Seeded gap-fill helpers ─────────────────────────────────────────────── // ── Seeded gap-fill helpers ───────────────────────────────────────────────
async fn setup_seed_db(pool: sqlx::SqlitePool) { async fn setup_seed_db(pool: sqlx::SqlitePool) {
utxo_funding::create_table(&pool).await; utxo_funding::create_table(&pool).await;
utxo_spending::create_table(&pool).await;
tx::create_table(&pool).await; tx::create_table(&pool).await;
pool::create_table(&pool).await; pool::create_table(&pool).await;
ohlcv::create_table(&pool).await; ohlcv::create_table(&pool).await;
dummy_init_seq(); dummy_init_seq();
} }
/// A confirmed trade against `pool_byte`, leaving it holding `reserves`.
async fn insert_trade_at( async fn insert_trade_at(
conn: &mut sqlx::pool::PoolConnection<sqlx::Sqlite>, conn: &mut sqlx::pool::PoolConnection<sqlx::Sqlite>,
token: &TokenID, token: &TokenID,
txid_byte: u8, txid_byte: u8,
pool_byte: u8,
ts: u64, ts: u64,
sats_delta: i64, reserves: (u64, i64),
token_delta: i64, deltas: (i64, i64),
) { ) {
let txid = Txid::from_byte_array([txid_byte; 32]); let txid = Txid::from_byte_array([txid_byte; 32]);
let utxo = OutPointHash::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 pool_hash = OutPointHash::from_byte_array([pool_byte; 32]);
let block = BlockHash::all_zeros(); let block = BlockHash::all_zeros();
let cauldron = dummy_cauldron( let cauldron = dummy_cauldron(
&txid, &txid,
&utxo, &utxo,
token, token,
sats_delta.unsigned_abs(), reserves.0,
token_delta, reserves.1,
&PubkeyHash::all_zeros(), &PubkeyHash::all_zeros(),
); );
insert_new_pool(
&mut **conn,
&dummy_cauldron(
&Txid::all_zeros(),
&pool_hash,
token,
0,
0,
&PubkeyHash::all_zeros(),
),
)
.await
.unwrap();
insert_utxo_funding(&mut **conn, &vec![cauldron.clone()], &txid) insert_utxo_funding(&mut **conn, &vec![cauldron.clone()], &txid)
.await .await
.unwrap(); .unwrap();
@ -241,8 +245,8 @@ async fn insert_trade_at(
&cauldron, &cauldron,
Some(ts), Some(ts),
Some(ts), Some(ts),
sats_delta, deltas.0,
token_delta, deltas.1,
) )
.await .await
.unwrap(); .unwrap();
@ -344,31 +348,30 @@ async fn test_multiple_candlesticks_endpoint() {
let cndl_array = json["candlesticks"].as_array().unwrap(); let cndl_array = json["candlesticks"].as_array().unwrap();
assert_eq!(cndl_array.len(), 2, "Should produce exactly two candles"); assert_eq!(cndl_array.len(), 2, "Should produce exactly two candles");
// ----- Candle #1 ----- // Each trade seeds a *different* pool, so the price is the running sum of all
// pools live at that moment, not the last one to trade:
// after T1 80k/2k = 40 after T2 200k/4k = 50
// after T3 360k/6k = 60 after T4 560k/8k = 70
// ----- Candle #1: [1727963300, 1727963900) holds T1 and T2 -----
let cndl1 = &cndl_array[0]; let cndl1 = &cndl_array[0];
// Candle #1 => time=1727963300
// trades at 1727963300 => ratio=40, 1727963600 => ratio=60
// open=40, close=60, low=40, high=60, volume_sats=200k, volume_tokens=4k, transaction_count=2
assert_eq!(cndl1["time"], 1727963300); assert_eq!(cndl1["time"], 1727963300);
assert!((cndl1["open"].as_f64().unwrap() - 40.0).abs() < f64::EPSILON); assert!((cndl1["open"].as_f64().unwrap() - 40.0).abs() < f64::EPSILON);
assert!((cndl1["close"].as_f64().unwrap() - 60.0).abs() < f64::EPSILON); assert!((cndl1["close"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON);
assert!((cndl1["low"].as_f64().unwrap() - 40.0).abs() < f64::EPSILON); assert!((cndl1["low"].as_f64().unwrap() - 40.0).abs() < f64::EPSILON);
assert!((cndl1["high"].as_f64().unwrap() - 60.0).abs() < f64::EPSILON); assert!((cndl1["high"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON);
assert_eq!(cndl1["volume_sats"].as_i64().unwrap(), 80_000 + 120_000); assert_eq!(cndl1["volume_sats"].as_i64().unwrap(), 80_000 + 120_000);
assert_eq!(cndl1["volume_tokens"].as_i64().unwrap(), 2_000 + 2_000); assert_eq!(cndl1["volume_tokens"].as_i64().unwrap(), 2_000 + 2_000);
assert_eq!(cndl1["transaction_count"].as_i64().unwrap(), 2); assert_eq!(cndl1["transaction_count"].as_i64().unwrap(), 2);
// ----- Candle #2 ----- // ----- Candle #2: [1727963900, 1727964500) holds T3 and T4 -----
let cndl2 = &cndl_array[1]; let cndl2 = &cndl_array[1];
// Candle #2 => time=1727963900
// trades at 1727963900 => ratio=80, 1727964200 => ratio=100
// open=80, close=100, low=80, high=100, volume_sats=360k, volume_tokens=4k, transaction_count=2
assert_eq!(cndl2["time"], 1727963900); assert_eq!(cndl2["time"], 1727963900);
assert!((cndl2["open"].as_f64().unwrap() - 80.0).abs() < f64::EPSILON); // opens where candle #1 closed — the series never gaps
assert!((cndl2["close"].as_f64().unwrap() - 100.0).abs() < f64::EPSILON); assert!((cndl2["open"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON);
assert!((cndl2["low"].as_f64().unwrap() - 80.0).abs() < f64::EPSILON); assert!((cndl2["close"].as_f64().unwrap() - 70.0).abs() < f64::EPSILON);
assert!((cndl2["high"].as_f64().unwrap() - 100.0).abs() < f64::EPSILON); assert!((cndl2["low"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON);
// volume_sats=160k+200k=360k, volume_tokens=2k+2k=4k, transaction_count=2 assert!((cndl2["high"].as_f64().unwrap() - 70.0).abs() < f64::EPSILON);
assert_eq!(cndl2["volume_sats"].as_i64().unwrap(), 160_000 + 200_000); assert_eq!(cndl2["volume_sats"].as_i64().unwrap(), 160_000 + 200_000);
assert_eq!(cndl2["volume_tokens"].as_i64().unwrap(), 4_000); assert_eq!(cndl2["volume_tokens"].as_i64().unwrap(), 4_000);
assert_eq!(cndl2["transaction_count"].as_i64().unwrap(), 2); assert_eq!(cndl2["transaction_count"].as_i64().unwrap(), 2);
@ -378,6 +381,7 @@ async fn test_multiple_candlesticks_endpoint() {
async fn test_single_swap_multiple_pools() { async fn test_single_swap_multiple_pools() {
let mock_db = mock_db_pool(|pool: sqlx::SqlitePool| async move { let mock_db = mock_db_pool(|pool: sqlx::SqlitePool| async move {
utxo_funding::create_table(&pool).await; utxo_funding::create_table(&pool).await;
utxo_spending::create_table(&pool).await;
tx::create_table(&pool).await; tx::create_table(&pool).await;
pool::create_table(&pool).await; pool::create_table(&pool).await;
dummy_init_seq(); dummy_init_seq();
@ -427,12 +431,11 @@ async fn test_single_swap_multiple_pools() {
.unwrap(); .unwrap();
} }
let token1 = TokenID::from_byte_array([0xda; 32]);
let pkh1 = PubkeyHash::from_byte_array([0xca; 20]); let pkh1 = PubkeyHash::from_byte_array([0xca; 20]);
for pool_hash in &pools { for pool_hash in &pools {
insert_new_pool( insert_new_pool(
&mut conn, &mut conn,
&dummy_cauldron(&Txid::all_zeros(), pool_hash, &token1, 0, 0, &pkh1), &dummy_cauldron(&Txid::all_zeros(), pool_hash, &token_zero, 0, 0, &pkh1),
) )
.await .await
.unwrap(); .unwrap();
@ -473,12 +476,18 @@ async fn test_single_swap_multiple_pools() {
assert_eq!(first_candle["volume_tokens"].as_i64().unwrap(), 2000 + 2000); assert_eq!(first_candle["volume_tokens"].as_i64().unwrap(), 2000 + 2000);
} }
/// A transaction whose legs cancel — buying from one pool and selling into another
/// in equal size — nets to zero tokens moved. It still moved every satoshi and token
/// its legs moved, and it still leaves both pools holding reserves to price from.
#[rocket::async_test] #[rocket::async_test]
async fn test_candle_carries_price_when_net_zero_tokens_but_has_volume() { async fn test_candle_prices_and_counts_volume_when_legs_cancel() {
// Fresh mock DB for this scenario let step: u64 = 600; // 10 minutes
let mock_db = mock_db_pool(|pool: sqlx::SqlitePool| async move { let t0: u64 = 1_700_000_000;
// --- boilerplate setup --- let t1: u64 = t0 + step;
let mock_db = mock_db_pool(move |pool: sqlx::SqlitePool| async move {
utxo_funding::create_table(&pool).await; utxo_funding::create_table(&pool).await;
utxo_spending::create_table(&pool).await;
tx::create_table(&pool).await; tx::create_table(&pool).await;
pool::create_table(&pool).await; pool::create_table(&pool).await;
dummy_init_seq(); dummy_init_seq();
@ -489,65 +498,21 @@ async fn test_candle_carries_price_when_net_zero_tokens_but_has_volume() {
let pkh_zero = PubkeyHash::all_zeros(); let pkh_zero = PubkeyHash::all_zeros();
let block_zero = BlockHash::all_zeros(); let block_zero = BlockHash::all_zeros();
// Two pools (identities)
let pool_a = OutPointHash::from_byte_array([0x1a; 32]); let pool_a = OutPointHash::from_byte_array([0x1a; 32]);
let pool_b = OutPointHash::from_byte_array([0x1b; 32]); let pool_b = OutPointHash::from_byte_array([0x1b; 32]);
for pool_hash in [&pool_a, &pool_b] {
insert_new_pool(
&mut conn,
&dummy_cauldron(&Txid::all_zeros(), pool_hash, &token_zero, 0, 0, &pkh_zero),
)
.await
.unwrap();
}
// Register pools in `pool` table // -------- Candle #1: an ordinary trade, leaving pool A at 10_000/200 = 50 --------
let token1 = TokenID::from_byte_array([0xda; 32]);
let pkh1 = PubkeyHash::from_byte_array([0xca; 20]);
insert_new_pool(
&mut conn,
&ParsedContract {
pkh: pkh1,
is_withdrawn: false,
spent_utxo_hash: OutPointHash::all_zeros(),
new_utxo_hash: Some(pool_a),
new_utxo_txid: Some(Txid::all_zeros()),
new_utxo_n: Some(0),
token_id: Some(token1),
sats: Some(0),
token_amount: Some(0),
},
)
.await
.unwrap();
insert_new_pool(
&mut conn,
&ParsedContract {
pkh: pkh1,
is_withdrawn: false,
spent_utxo_hash: OutPointHash::all_zeros(),
new_utxo_hash: Some(pool_b),
new_utxo_txid: Some(Txid::all_zeros()),
new_utxo_n: Some(0),
token_id: Some(token1),
sats: Some(0),
token_amount: Some(0),
},
)
.await
.unwrap();
// Times and step
let step: u64 = 600; // 10 minutes
let t0: u64 = 1_700_000_000;
let t1: u64 = t0 + step;
// -------- Candle #1 (normal priceable trade) --------
let txid_price = Txid::from_byte_array([0x90; 32]); let txid_price = Txid::from_byte_array([0x90; 32]);
let utxo_p = OutPointHash::from_byte_array([0x21; 32]); let utxo_p = OutPointHash::from_byte_array([0x21; 32]);
let cauldron_p = ParsedContract { let cauldron_p = dummy_cauldron(&txid_price, &utxo_p, &token_zero, 10_000, 200, &pkh_zero);
pkh: pkh_zero,
is_withdrawn: false,
spent_utxo_hash: OutPointHash::all_zeros(),
new_utxo_hash: Some(utxo_p),
new_utxo_txid: Some(txid_price),
new_utxo_n: Some(0),
token_id: Some(token_zero),
sats: Some(0),
token_amount: Some(0),
};
insert_utxo_funding(&mut conn, &vec![cauldron_p.clone()], &txid_price) insert_utxo_funding(&mut conn, &vec![cauldron_p.clone()], &txid_price)
.await .await
.unwrap(); .unwrap();
@ -555,7 +520,6 @@ async fn test_candle_carries_price_when_net_zero_tokens_but_has_volume() {
.await .await
.unwrap(); .unwrap();
insert_mempool_tx(&mut conn, &txid_price, t0).await.unwrap(); insert_mempool_tx(&mut conn, &txid_price, t0).await.unwrap();
// sats_delta=+10_000, token_delta=+200 -> price = 10000/200 = 50
pool::insert_pool_history_entry( pool::insert_pool_history_entry(
&mut conn, &mut conn,
&pool_a, &pool_a,
@ -568,27 +532,25 @@ async fn test_candle_carries_price_when_net_zero_tokens_but_has_volume() {
.await .await
.unwrap(); .unwrap();
// -------- Candle #2 (net zero tokens but non-zero volume) -------- // -------- Candle #2: two legs that cancel to zero net tokens --------
// A ends at 20_000/400, B at 30_000/600 — both still 50, so the aggregate holds.
let txid_net0 = Txid::from_byte_array([0x91; 32]); let txid_net0 = Txid::from_byte_array([0x91; 32]);
let utxo_a1 = OutPointHash::from_byte_array([0x22; 32]); let ca_a1 = dummy_cauldron(
let utxo_b1 = OutPointHash::from_byte_array([0x23; 32]); &txid_net0,
&OutPointHash::from_byte_array([0x22; 32]),
let ca_a1 = ParsedContract { &token_zero,
pkh: pkh_zero, 20_000,
is_withdrawn: false, 400,
spent_utxo_hash: OutPointHash::all_zeros(), &pkh_zero,
new_utxo_hash: Some(utxo_a1), );
new_utxo_txid: Some(txid_net0), let ca_b1 = dummy_cauldron(
new_utxo_n: Some(0), &txid_net0,
token_id: Some(token_zero), &OutPointHash::from_byte_array([0x23; 32]),
sats: Some(0), &token_zero,
token_amount: Some(0), 30_000,
}; 600,
let ca_b1 = ParsedContract { &pkh_zero,
new_utxo_hash: Some(utxo_b1), );
new_utxo_txid: Some(txid_net0),
..ca_a1
};
insert_utxo_funding(&mut conn, &vec![ca_a1.clone()], &txid_net0) insert_utxo_funding(&mut conn, &vec![ca_a1.clone()], &txid_net0)
.await .await
@ -612,7 +574,7 @@ async fn test_candle_carries_price_when_net_zero_tokens_but_has_volume() {
) )
.await .await
.unwrap(); .unwrap();
// Opposite deltas within the same tx // Opposite deltas within the same transaction
pool::insert_pool_history_entry( pool::insert_pool_history_entry(
&mut conn, &mut conn,
&pool_b, &pool_b,
@ -636,11 +598,10 @@ async fn test_candle_carries_price_when_net_zero_tokens_but_has_volume() {
.expect("valid rocket instance"); .expect("valid rocket instance");
let token_id_zero = "0000000000000000000000000000000000000000000000000000000000000000"; let token_id_zero = "0000000000000000000000000000000000000000000000000000000000000000";
let start = 1_700_000_000u64; let end = t0 + 2 * step;
let end = start + 2 * 600;
let response = client let response = client
.get(format!( .get(format!(
"/api/price/{token_id_zero}/candlesticks?start={start}&end={end}&stepsize=600" "/api/price/{token_id_zero}/candlesticks?start={t0}&end={end}&stepsize=600"
)) ))
.dispatch() .dispatch()
.await; .await;
@ -652,29 +613,18 @@ async fn test_candle_carries_price_when_net_zero_tokens_but_has_volume() {
assert_eq!(candles.len(), 2, "Expected 2 candles (two intervals)"); assert_eq!(candles.len(), 2, "Expected 2 candles (two intervals)");
// --- Candle #1 (priceable) ---
let c1 = &candles[0]; let c1 = &candles[0];
assert_eq!(c1["time"].as_i64().unwrap(), start as i64); assert_eq!(c1["time"].as_i64().unwrap(), t0 as i64);
// price = 10000 / 200 = 50
assert!((c1["open"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON);
assert!((c1["close"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON); assert!((c1["close"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON);
assert!((c1["low"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON);
assert!((c1["high"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON);
assert_eq!(c1["volume_sats"].as_i64().unwrap(), 10_000); assert_eq!(c1["volume_sats"].as_i64().unwrap(), 10_000);
assert_eq!(c1["volume_tokens"].as_i64().unwrap(), 200); assert_eq!(c1["volume_tokens"].as_i64().unwrap(), 200);
assert_eq!(c1["transaction_count"].as_i64().unwrap(), 1); assert_eq!(c1["transaction_count"].as_i64().unwrap(), 1);
// --- Candle #2 (legs cancel to zero net tokens) ---
let c2 = &candles[1]; let c2 = &candles[1];
assert_eq!(c2["time"].as_i64().unwrap(), (start + 600) as i64); assert_eq!(c2["time"].as_i64().unwrap(), (t0 + step) as i64);
// Both legs executed at 50, so the gross ratio 20_000/400 prices the tx at 50 // 50_000 sats over 1_000 tokens across both pools
// directly. (Before gross pricing this candle carried the previous close because
// the net token delta was zero; the value coincides, the derivation does not.)
assert!((c2["open"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON);
assert!((c2["close"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON); assert!((c2["close"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON);
assert!((c2["low"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON); // Volume sums the absolute per-pool deltas: the signed sum would be zero.
assert!((c2["high"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON);
// Volume sums absolute per-pool deltas within the tx
assert_eq!(c2["volume_sats"].as_i64().unwrap(), 20_000); assert_eq!(c2["volume_sats"].as_i64().unwrap(), 20_000);
assert_eq!(c2["volume_tokens"].as_i64().unwrap(), 400); assert_eq!(c2["volume_tokens"].as_i64().unwrap(), 400);
assert_eq!(c2["transaction_count"].as_i64().unwrap(), 1); assert_eq!(c2["transaction_count"].as_i64().unwrap(), 1);
@ -686,9 +636,12 @@ async fn test_candle_carries_price_when_net_zero_tokens_but_has_volume() {
// TIME_1=1727963400, TIME_2=1727963600, TIME_3=1727963900 → hour bucket 1727960400 // TIME_1=1727963400, TIME_2=1727963600, TIME_3=1727963900 → hour bucket 1727960400
// TIME_4=1727964200 → hour bucket 1727964000 // TIME_4=1727964200 → hour bucket 1727964000
// //
// Expected OHLCV (bucket 1727960400): open=40, close=80, high=80, low=40 // Each trade seeds its own pool, so the price is the running sum over all live
// pools: 40 after T1, 50 after T2, 60 after T3, 70 after T4.
//
// Expected OHLCV (bucket 1727960400): open=40, close=60, high=60, low=40
// vol_sats=360_000 (80k+120k+160k), vol_tokens=6_000, tx_count=3 // vol_sats=360_000 (80k+120k+160k), vol_tokens=6_000, tx_count=3
// Expected OHLCV (bucket 1727964000): open=close=high=low=100 // Expected OHLCV (bucket 1727964000): open=60, close=high=70, low=60
// vol_sats=200_000, vol_tokens=2_000, tx_count=1 // vol_sats=200_000, vol_tokens=2_000, tx_count=1
/// Full range served from ohlcv_1h (materialized_end covers everything). /// Full range served from ohlcv_1h (materialized_end covers everything).
@ -734,8 +687,8 @@ async fn test_ohlcv_fast_path_full_range() {
let c1 = &candles[0]; let c1 = &candles[0];
assert_eq!(c1["time"].as_i64().unwrap(), 1727960400); assert_eq!(c1["time"].as_i64().unwrap(), 1727960400);
assert!((c1["open"].as_f64().unwrap() - 40.0).abs() < f64::EPSILON); assert!((c1["open"].as_f64().unwrap() - 40.0).abs() < f64::EPSILON);
assert!((c1["close"].as_f64().unwrap() - 80.0).abs() < f64::EPSILON); assert!((c1["close"].as_f64().unwrap() - 60.0).abs() < f64::EPSILON);
assert!((c1["high"].as_f64().unwrap() - 80.0).abs() < f64::EPSILON); assert!((c1["high"].as_f64().unwrap() - 60.0).abs() < f64::EPSILON);
assert!((c1["low"].as_f64().unwrap() - 40.0).abs() < f64::EPSILON); assert!((c1["low"].as_f64().unwrap() - 40.0).abs() < f64::EPSILON);
assert_eq!(c1["volume_sats"].as_i64().unwrap(), 360_000); assert_eq!(c1["volume_sats"].as_i64().unwrap(), 360_000);
assert_eq!(c1["volume_tokens"].as_i64().unwrap(), 6_000); assert_eq!(c1["volume_tokens"].as_i64().unwrap(), 6_000);
@ -743,8 +696,8 @@ async fn test_ohlcv_fast_path_full_range() {
let c2 = &candles[1]; let c2 = &candles[1];
assert_eq!(c2["time"].as_i64().unwrap(), 1727964000); assert_eq!(c2["time"].as_i64().unwrap(), 1727964000);
assert!((c2["open"].as_f64().unwrap() - 100.0).abs() < f64::EPSILON); assert!((c2["open"].as_f64().unwrap() - 60.0).abs() < f64::EPSILON);
assert!((c2["close"].as_f64().unwrap() - 100.0).abs() < f64::EPSILON); assert!((c2["close"].as_f64().unwrap() - 70.0).abs() < f64::EPSILON);
assert_eq!(c2["volume_sats"].as_i64().unwrap(), 200_000); assert_eq!(c2["volume_sats"].as_i64().unwrap(), 200_000);
assert_eq!(c2["volume_tokens"].as_i64().unwrap(), 2_000); assert_eq!(c2["volume_tokens"].as_i64().unwrap(), 2_000);
assert_eq!(c2["transaction_count"].as_i64().unwrap(), 1); assert_eq!(c2["transaction_count"].as_i64().unwrap(), 1);
@ -796,16 +749,18 @@ async fn test_ohlcv_fast_path_with_raw_tail() {
let c1 = &candles[0]; let c1 = &candles[0];
assert_eq!(c1["time"].as_i64().unwrap(), 1727960400); assert_eq!(c1["time"].as_i64().unwrap(), 1727960400);
assert!((c1["open"].as_f64().unwrap() - 40.0).abs() < f64::EPSILON); assert!((c1["open"].as_f64().unwrap() - 40.0).abs() < f64::EPSILON);
assert!((c1["close"].as_f64().unwrap() - 80.0).abs() < f64::EPSILON); assert!((c1["close"].as_f64().unwrap() - 60.0).abs() < f64::EPSILON);
assert_eq!(c1["volume_sats"].as_i64().unwrap(), 360_000); assert_eq!(c1["volume_sats"].as_i64().unwrap(), 360_000);
assert_eq!(c1["volume_tokens"].as_i64().unwrap(), 6_000); assert_eq!(c1["volume_tokens"].as_i64().unwrap(), 6_000);
assert_eq!(c1["transaction_count"].as_i64().unwrap(), 3); assert_eq!(c1["transaction_count"].as_i64().unwrap(), 3);
// Candle 2 came from the raw CTE tail. // Candle 2 came from the raw tail. Its open must be the price carried out of
// the materialised bucket — the tail takes its own snapshot at the seam, so a
// snapshot that missed the three pools T1–T3 seeded would open at 100 here.
let c2 = &candles[1]; let c2 = &candles[1];
assert_eq!(c2["time"].as_i64().unwrap(), 1727964000); assert_eq!(c2["time"].as_i64().unwrap(), 1727964000);
assert!((c2["open"].as_f64().unwrap() - 100.0).abs() < f64::EPSILON); assert!((c2["open"].as_f64().unwrap() - 60.0).abs() < f64::EPSILON);
assert!((c2["close"].as_f64().unwrap() - 100.0).abs() < f64::EPSILON); assert!((c2["close"].as_f64().unwrap() - 70.0).abs() < f64::EPSILON);
assert_eq!(c2["volume_sats"].as_i64().unwrap(), 200_000); assert_eq!(c2["volume_sats"].as_i64().unwrap(), 200_000);
assert_eq!(c2["volume_tokens"].as_i64().unwrap(), 2_000); assert_eq!(c2["volume_tokens"].as_i64().unwrap(), 2_000);
assert_eq!(c2["transaction_count"].as_i64().unwrap(), 1); assert_eq!(c2["transaction_count"].as_i64().unwrap(), 1);
@ -838,8 +793,8 @@ async fn test_ohlcv_skipped_for_non_aligned_start() {
// Start is NOT hour-aligned (1727963300 % 3600 != 0) — must fall back to raw. // Start is NOT hour-aligned (1727963300 % 3600 != 0) — must fall back to raw.
// With stepsize=3600 the first interval is [1727963300, 1727966900). // With stepsize=3600 the first interval is [1727963300, 1727966900).
// All four trades (T1–T4) fall within this single interval: // All four trades (T1–T4) fall within this single interval:
// candle 1 at 1727963300: open=40 (T1 first), close=100 (T4 last), tx_count=4 // candle 1 at 1727963300: open=40 (T1 first), close=70 (all four pools), tx_count=4
// candle 2 at 1727966900: flat carry-forward at 100 (no trades) // candle 2 at 1727966900: flat carry-forward at 70 (no trades)
let response = client let response = client
.get(format!( .get(format!(
"/api/price/{token_id_zero}/candlesticks\ "/api/price/{token_id_zero}/candlesticks\
@ -858,7 +813,7 @@ async fn test_ohlcv_skipped_for_non_aligned_start() {
assert_eq!(candles.len(), 2); assert_eq!(candles.len(), 2);
assert_eq!(candles[0]["time"].as_i64().unwrap(), 1727963300); assert_eq!(candles[0]["time"].as_i64().unwrap(), 1727963300);
assert!((candles[0]["open"].as_f64().unwrap() - 40.0).abs() < f64::EPSILON); assert!((candles[0]["open"].as_f64().unwrap() - 40.0).abs() < f64::EPSILON);
assert!((candles[0]["close"].as_f64().unwrap() - 100.0).abs() < f64::EPSILON); assert!((candles[0]["close"].as_f64().unwrap() - 70.0).abs() < f64::EPSILON);
assert_eq!(candles[0]["transaction_count"].as_i64().unwrap(), 4); assert_eq!(candles[0]["transaction_count"].as_i64().unwrap(), 4);
// Flat carry-forward candle (no trades in second interval). // Flat carry-forward candle (no trades in second interval).
assert_eq!(candles[1]["time"].as_i64().unwrap(), 1727966900); assert_eq!(candles[1]["time"].as_i64().unwrap(), 1727966900);
@ -877,7 +832,16 @@ async fn test_raw_path_seeded_gap_fill_no_in_window_trades() {
let db = mock_db_pool(move |pool: sqlx::SqlitePool| async move { let db = mock_db_pool(move |pool: sqlx::SqlitePool| async move {
setup_seed_db(pool.clone()).await; setup_seed_db(pool.clone()).await;
let mut conn = pool.acquire().await.unwrap(); let mut conn = pool.acquire().await.unwrap();
insert_trade_at(&mut conn, &token_copy, 0x10, 1_000, 100_000, 2_000).await; insert_trade_at(
&mut conn,
&token_copy,
0x10,
0x90,
1_000,
(100_000, 2_000),
(100_000, 2_000),
)
.await;
}) })
.await; .await;
@ -921,8 +885,27 @@ async fn test_raw_path_seeded_gap_fill_then_in_window_trade() {
let db = mock_db_pool(move |pool: sqlx::SqlitePool| async move { let db = mock_db_pool(move |pool: sqlx::SqlitePool| async move {
setup_seed_db(pool.clone()).await; setup_seed_db(pool.clone()).await;
let mut conn = pool.acquire().await.unwrap(); let mut conn = pool.acquire().await.unwrap();
insert_trade_at(&mut conn, &token_copy, 0x20, 1_000, 100_000, 2_000).await; // Both trades hit the same pool, so the window's price is that pool's.
insert_trade_at(&mut conn, &token_copy, 0x21, 2_500, 150_000, 2_000).await; insert_trade_at(
&mut conn,
&token_copy,
0x20,
0x91,
1_000,
(100_000, 2_000),
(100_000, 2_000),
)
.await;
insert_trade_at(
&mut conn,
&token_copy,
0x21,
0x91,
2_500,
(150_000, 2_000),
(50_000, 0),
)
.await;
}) })
.await; .await;
@ -981,7 +964,16 @@ async fn test_raw_path_no_seed_no_prefill() {
let db = mock_db_pool(move |pool: sqlx::SqlitePool| async move { let db = mock_db_pool(move |pool: sqlx::SqlitePool| async move {
setup_seed_db(pool.clone()).await; setup_seed_db(pool.clone()).await;
let mut conn = pool.acquire().await.unwrap(); let mut conn = pool.acquire().await.unwrap();
insert_trade_at(&mut conn, &token_copy, 0x30, 2_500, 150_000, 2_000).await; insert_trade_at(
&mut conn,
&token_copy,
0x30,
0x92,
2_500,
(150_000, 2_000),
(150_000, 2_000),
)
.await;
}) })
.await; .await;