riftenlabs-indexer/src/db/cauldron/ohlcv.rs
2026-08-11 15:03:50 +02:00

733 lines
23 KiB
Rust

// Copyright (C) 2024-2026 Whiterun LLC
//
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use std::collections::HashMap;
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 sqlx::{Row, SqlitePool};
/// Bumped whenever the maths that fills `ohlcv_1h` changes, so buckets materialised by
/// an older rule are discarded instead of being served forever — `rebuild_range` uses
/// `INSERT OR IGNORE`, so existing rows are never corrected in place.
///
/// 2: price switched from the signed net ratio to the gross volume ratio.
/// 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";
pub async fn create_table(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE IF NOT EXISTS ohlcv_1h (
token_id BLOB NOT NULL,
bucket_ts INTEGER NOT NULL,
open REAL NOT NULL,
high REAL NOT NULL,
low REAL NOT NULL,
close REAL NOT NULL,
volume_sats INTEGER NOT NULL,
volume_tokens INTEGER NOT NULL,
tx_count INTEGER NOT NULL,
PRIMARY KEY (token_id, bucket_ts)
)",
)
.execute(pool)
.await
.expect("failed to create ohlcv_1h table");
}
/// Discards `ohlcv_1h` when it was materialised under an older pricing rule.
///
/// Returns `true` when the table was cleared. The caller must then leave repopulation
/// to the incremental background task: re-materialising the whole history inline would
/// hold up Rocket's startup for as long as it takes, and the raw query path already
/// serves correct candles from `pool_history_entry` while the table refills.
pub async fn migrate_if_stale(read_pool: &SqlitePool, write_pool: &SqlitePool) -> Result<bool> {
let stored = config_get(read_pool, OHLCV_VERSION_KEY)
.await?
.and_then(|v| v.parse::<u32>().ok());
if stored == Some(OHLCV_VERSION) {
return Ok(false);
}
let mut tx = write_pool.begin().await?;
sqlx::query("DELETE FROM ohlcv_1h")
.execute(&mut *tx)
.await?;
config_set(&mut *tx, OHLCV_VERSION_KEY, &OHLCV_VERSION.to_string()).await;
tx.commit().await?;
Ok(true)
}
/// Returns the highest `bucket_ts` in `ohlcv_1h`, or `None` if the table is empty.
pub async fn get_max_bucket_ts(pool: &SqlitePool) -> Result<Option<i64>> {
let row: Option<(Option<i64>,)> = sqlx::query_as("SELECT MAX(bucket_ts) FROM ohlcv_1h")
.fetch_optional(pool)
.await?;
Ok(row.and_then(|r| r.0))
}
/// Returns the earliest confirmed trade timestamp floored to the nearest 1-hour bucket,
/// or `None` if there are no confirmed trades. Used to seed the initial backfill start
/// so the background task doesn't scan from Unix epoch 0.
pub async fn get_min_trade_bucket_ts(pool: &SqlitePool) -> Result<Option<i64>> {
let row: Option<(Option<i64>,)> = sqlx::query_as(
"SELECT (MIN(phe.effective_timestamp) / 3600) * 3600
FROM pool_history_entry AS phe
WHERE phe.mtp_timestamp IS NOT NULL",
)
.fetch_optional(pool)
.await?;
Ok(row.and_then(|r| r.0))
}
/// Gross traded volume per (token, bucket): `(volume_sats, volume_tokens, tx_count)`.
///
/// Volumes are gross sums of the absolute per-leg deltas. Summing the *signed*
/// 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.
/// Returns the number of rows inserted.
pub async fn rebuild_range(
read_pool: &SqlitePool,
write_pool: &SqlitePool,
since_ts: i64,
until_ts: i64,
) -> Result<u64> {
if since_ts >= until_ts {
return Ok(0);
}
// Phase 1: read and replay using the read pool — no write lock held throughout.
let mut events =
spot::load_events(read_pool, None, since_ts, until_ts, Confirmed::Only).await?;
if events.is_empty() {
return Ok(0);
}
let mut snapshots = spot::load_snapshot(read_pool, None, since_ts, Confirmed::Only).await?;
let volumes = load_bucket_volumes(read_pool, since_ts, until_ts).await?;
struct Materialised {
token_id: TokenKey,
bucket_ts: i64,
open: f64,
high: f64,
low: f64,
close: f64,
volume_sats: i64,
volume_tokens: i64,
tx_count: i64,
}
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);
}
// Phase 2: insert pre-computed rows inside a single transaction.
// The write lock is held only for these fast INSERTs, not during the replay.
let mut tx = write_pool.begin().await?;
let mut inserted = 0u64;
for row in pending {
inserted += sqlx::query(
"INSERT OR IGNORE INTO ohlcv_1h
(token_id, bucket_ts, open, high, low, close, volume_sats, volume_tokens, tx_count)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(row.token_id)
.bind(row.bucket_ts)
.bind(row.open)
.bind(row.high)
.bind(row.low)
.bind(row.close)
.bind(row.volume_sats)
.bind(row.volume_tokens)
.bind(row.tx_count)
.execute(&mut *tx)
.await?
.rows_affected();
}
tx.commit().await?;
Ok(inserted)
}
pub struct OhlcvRow {
pub bucket_ts: i64,
pub open: f64,
pub high: f64,
pub low: f64,
pub close: f64,
pub volume_sats: i64,
pub volume_tokens: i64,
pub tx_count: i64,
}
/// Return the materialised 1-hour candles for a single token in `[start, end)`.
/// Only buckets that had at least one trade are returned (gaps must be filled by the caller).
pub async fn get_active_candles(
pool: &SqlitePool,
token_blob: &[u8],
start: i64,
end: i64,
) -> Result<Vec<OhlcvRow>> {
let rows = sqlx::query(
"SELECT bucket_ts, open, high, low, close, volume_sats, volume_tokens, tx_count
FROM ohlcv_1h
WHERE token_id = ? AND bucket_ts >= ? AND bucket_ts < ?
ORDER BY bucket_ts ASC",
)
.bind(token_blob)
.bind(start)
.bind(end)
.fetch_all(pool)
.await?;
Ok(rows
.into_iter()
.map(|r| OhlcvRow {
bucket_ts: r.get(0),
open: r.get(1),
high: r.get(2),
low: r.get(3),
close: r.get(4),
volume_sats: r.get(5),
volume_tokens: r.get(6),
tx_count: r.get(7),
})
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::cauldron::{pool as cauldron_pool, tx, utxo_funding, utxo_spending};
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
use std::sync::atomic::{AtomicU64, Ordering};
static OHLCV_TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
const HOUR: i64 = 3600;
async fn test_pool() -> SqlitePool {
let id = OHLCV_TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
let uri = format!("file:ohlcv_test_{}?mode=memory&cache=shared", id);
let opts = SqliteConnectOptions::new()
.filename(&uri)
.foreign_keys(false);
SqlitePoolOptions::new().connect_with(opts).await.unwrap()
}
async fn setup_db(pool: &SqlitePool) {
tx::create_table(pool).await;
utxo_funding::create_table(pool).await;
utxo_spending::create_table(pool).await;
cauldron_pool::create_table(pool).await;
create_table(pool).await; // ohlcv_1h + idx_phe_txid
}
/// One trade leg, inserted via raw SQL (FK disabled in tests).
///
/// `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],
utxo: [u8; 32],
pool_hash: [u8; 32],
token_id: [u8; 32],
ts: i64,
confirmed: bool,
reserves: (i64, i64),
deltas: (i64, i64),
) {
if confirmed {
sqlx::query(
"INSERT OR IGNORE INTO tx (txid, blockhash, mtp_timestamp) VALUES (?, ?, ?)",
)
.bind(txid.as_slice())
.bind([0xAA_u8; 32].as_slice())
.bind(ts)
.execute(conn)
.await
.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(
"INSERT INTO utxo_funding (new_utxo_hash, txid, spent_utxo_hash, new_utxo_txid, new_utxo_n, sats, token_amount, token_id)
VALUES (?, ?, ?, ?, 0, ?, ?, ?)",
)
.bind(utxo.as_slice())
.bind(txid.as_slice())
.bind([0u8; 32].as_slice())
.bind(txid.as_slice())
.bind(reserves.0)
.bind(reserves.1)
.bind(token_id.as_slice())
.execute(conn)
.await
.unwrap();
let seq: i64 =
sqlx::query_scalar("SELECT IFNULL(MAX(sequence), 0) + 1 FROM pool_history_entry")
.fetch_one(conn)
.await
.unwrap();
let ts_column = if confirmed {
"mtp_timestamp"
} else {
"first_seen_timestamp"
};
sqlx::query(&format!(
"INSERT INTO pool_history_entry
(utxo, pool, token_id, txid, tx_pos, {ts_column}, sequence, sats, token_amount, sats_delta, token_delta)
VALUES (?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)"
))
.bind(utxo.as_slice())
.bind(pool_hash.as_slice())
.bind(token_id.as_slice())
.bind(txid.as_slice())
.bind(ts)
.bind(seq)
.bind(reserves.0)
.bind(reserves.1)
.bind(deltas.0)
.bind(deltas.1)
.execute(conn)
.await
.unwrap();
}
/// A confirmed single-pool trade whose reserves are irrelevant to the assertion.
async fn insert_confirmed_trade(
conn: &SqlitePool,
txid: [u8; 32],
utxo: [u8; 32],
token_id: [u8; 32],
ts: i64,
sats_delta: i64,
token_delta: i64,
) {
insert_leg(
conn,
txid,
utxo,
[0xBB; 32],
token_id,
ts,
true,
(1000, 1000),
(sats_delta, token_delta),
)
.await;
}
async fn insert_mempool_trade(
conn: &SqlitePool,
txid: [u8; 32],
utxo: [u8; 32],
token_id: [u8; 32],
ts: i64,
) {
insert_leg(
conn,
txid,
utxo,
[0xBB; 32],
token_id,
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(token_id.as_slice())
.fetch_all(conn)
.await
.unwrap()
}
/// `get_min_trade_bucket_ts` should floor a mid-hour timestamp to the hour boundary.
#[tokio::test]
async fn test_get_min_trade_bucket_ts_floors_to_hour() {
let pool = test_pool().await;
setup_db(&pool).await;
// Trade at 1727963400 — not hour-aligned; floor to 1727960400
insert_confirmed_trade(
&pool, [0x01; 32], [0x02; 32], [0x03; 32], 1727963400, -1000, 25,
)
.await;
let result = get_min_trade_bucket_ts(&pool).await.unwrap();
assert_eq!(
result,
Some(1727960400),
"1727963400 should floor to 1727960400"
);
}
/// `get_min_trade_bucket_ts` returns `None` when only unconfirmed (mempool) trades exist.
#[tokio::test]
async fn test_get_min_trade_bucket_ts_no_confirmed_trades() {
let pool = test_pool().await;
setup_db(&pool).await;
insert_mempool_trade(&pool, [0x01; 32], [0x02; 32], [0x03; 32], 1727963400).await;
let result = get_min_trade_bucket_ts(&pool).await.unwrap();
assert_eq!(result, None, "mempool-only trades must not be returned");
}
/// Running `rebuild_range` twice on the same range inserts nothing on the second call
/// because INSERT OR IGNORE skips rows that already exist.
#[tokio::test]
async fn test_rebuild_range_idempotent() {
let pool = test_pool().await;
setup_db(&pool).await;
insert_confirmed_trade(
&pool, [0x01; 32], [0x02; 32], [0x03; 32], 1727963400, -1000, 25,
)
.await;
let n1 = rebuild_range(&pool, &pool, 1727960400, 1727964000)
.await
.unwrap();
assert!(n1 > 0, "first rebuild should insert at least one bucket");
let n2 = rebuild_range(&pool, &pool, 1727960400, 1727964000)
.await
.unwrap();
assert_eq!(
n2, 0,
"second rebuild must insert nothing (INSERT OR IGNORE)"
);
}
/// Unconfirmed trades (tx.blockhash IS NULL) must not appear in `ohlcv_1h`.
#[tokio::test]
async fn test_rebuild_range_excludes_mempool() {
let pool = test_pool().await;
setup_db(&pool).await;
insert_mempool_trade(&pool, [0x01; 32], [0x02; 32], [0x03; 32], 1727963400).await;
let n = rebuild_range(&pool, &pool, 1727960400, 1727967600)
.await
.unwrap();
assert_eq!(n, 0, "mempool trades must not be materialised");
}
/// The reason for `OHLCV_VERSION` 3.
///
/// 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]
async fn test_rebuild_range_prices_by_reserves_not_execution_average() {
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,
(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 txid = [0x01_u8; 32];
// Same transaction, two pools, opposite directions netting to +2 token units.
insert_leg(
&pool,
txid,
[0x02; 32],
[0xB1; 32],
token,
HOUR,
true,
(1_000_000, 3_000_000),
(-446_491_239, 1_334_527_069),
)
.await;
insert_leg(
&pool,
txid,
[0x04; 32],
[0xB2; 32],
token,
HOUR,
true,
(2_000_000, 6_000_000),
(384_906_040, -1_334_527_067),
)
.await;
rebuild_range(&pool, &pool, 0, 2 * HOUR).await.unwrap();
let (close, volume_sats, volume_tokens, tx_count): (f64, i64, i64, i64) = sqlx::query_as(
"SELECT close, volume_sats, volume_tokens, tx_count FROM ohlcv_1h WHERE token_id = ?",
)
.bind(token.as_slice())
.fetch_one(&pool)
.await
.unwrap();
assert!(
(close - 3_000_000.0 / 9_000_000.0).abs() < 1e-12,
"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"
);
}
/// The version key gates the wipe: stale tables are cleared exactly once.
#[tokio::test]
async fn test_migrate_if_stale_clears_once() {
let pool = test_pool().await;
setup_db(&pool).await;
crate::db::cauldron::config::create_table(&pool).await;
insert_confirmed_trade(
&pool, [0x01; 32], [0x02; 32], [0x03; 32], 1727963400, -1000, 25,
)
.await;
rebuild_range(&pool, &pool, 1727960400, 1727964000)
.await
.unwrap();
assert!(
migrate_if_stale(&pool, &pool).await.unwrap(),
"unversioned table must be wiped"
);
let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ohlcv_1h")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(remaining, 0, "stale buckets must be gone");
rebuild_range(&pool, &pool, 1727960400, 1727964000)
.await
.unwrap();
assert!(
!migrate_if_stale(&pool, &pool).await.unwrap(),
"a table at the current version must survive"
);
let kept: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM ohlcv_1h")
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(kept, 1, "rebuilt buckets must not be wiped again");
}
}