Price candlesticks by gross volume instead of net deltas

A transaction that trades against several pools of the same token had its
price computed as |SUM(sats_delta) / SUM(token_delta)|. Arbitrage routers buy
from one pool and sell into the others, so the token deltas very nearly
cancel, and the division turned real satoshis into a price no leg ever traded
at. Mainnet token NWB printed 30,792,599.5 sats/unit from a 27-pool sweep
whose legs all executed between 0.288 and 0.335 — a hundred-million-fold
error, and the visible spike on its chart. 820 such transactions exist across
91 tokens.

Price is now SUM(ABS(sats_delta)) / SUM(ABS(token_delta)): the volume-weighted
average of the prices the transaction's legs actually executed at, which is
always bounded by its cheapest and dearest leg. For single-direction
transactions — 99.76% of all prints, including the OLA supply-shock crash —
this is arithmetically identical to the old formula, so honest history is
untouched.

Transactions whose legs cancel exactly used to print nothing and let the
candle carry the previous close; they now price from their legs like any
other trade.

ohlcv_1h is materialised with INSERT OR IGNORE and the materialiser only ever
moves forward, so contaminated buckets could never be corrected in place. An
ohlcv_version config key clears the table once when the pricing rule changes.
The synchronous post-IBD backfill is skipped on that pass: it runs before
rocket::build() returns, so rebuilding all of history there would refuse
connections for the duration instead of falling back to the raw query path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jakobsn 2026-07-28 10:07:12 +02:00
parent 4a23ce70d4
commit 283c0c3e2c
5 changed files with 346 additions and 98 deletions

View file

@ -71,7 +71,7 @@ impl PriceInterval {
}
fn aggregate_raw_trades(
all_trades: &[(i64, i64, i64, i64, i64)],
all_trades: &[(i64, i64, i64)],
intervals: Vec<PriceInterval>,
step_size: i64,
mut found_first_trade: bool,
@ -87,7 +87,7 @@ fn aggregate_raw_trades(
let mut first_trade_in_interval = true;
while trade_index < all_trades.len() {
let (ts, signed_sats, signed_tokens, vol_sats, vol_tokens) = all_trades[trade_index];
let (ts, vol_sats, vol_tokens) = all_trades[trade_index];
if ts < interval_start {
trade_index += 1;
continue;
@ -96,8 +96,8 @@ fn aggregate_raw_trades(
break;
}
if signed_tokens != 0 {
let price = (signed_sats as f64 / signed_tokens as f64).abs();
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;
@ -206,59 +206,33 @@ fn fill_ohlcv_candles(
(result, found_first_trade, last_close)
}
/// 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
/// `volume_sats / volume_tokens` is the volume-weighted average of the prices actually
/// executed by that transaction's legs, and is therefore always bounded by the cheapest
/// and dearest leg. 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 divide real satoshis by a near-zero remainder, fabricating a
/// price no leg ever traded at.
async fn fetch_raw_trades(
pool: &SqlitePool,
token_blob: &[u8],
timestamp_start: i64,
timestamp_end: i64,
) -> Result<Vec<(i64, i64, i64, i64, i64)>> {
) -> Result<Vec<(i64, i64, i64)>> {
let sql = r#"
WITH per_pool_tx_raw AS (
SELECT
phe.txid,
SELECT
phe.effective_timestamp,
phe.utxo,
phe.sats_delta,
phe.token_delta,
phe.sequence
FROM pool_history_entry AS phe
WHERE phe.token_id = ?
SUM(ABS(phe.sats_delta)) AS volume_sats,
SUM(ABS(phe.token_delta)) AS volume_tokens,
MIN(phe.sequence) AS min_sequence
FROM pool_history_entry AS phe
WHERE phe.token_id = ?
AND phe.effective_timestamp >= ?
AND phe.effective_timestamp < ?
),
per_pool_tx AS (
SELECT
txid,
effective_timestamp,
utxo,
MIN(sequence) AS min_sequence,
SUM(sats_delta) AS signed_sats_pool,
SUM(token_delta) AS signed_tokens_pool,
SUM(ABS(sats_delta)) AS volume_sats_pool,
SUM(ABS(token_delta)) AS volume_tokens_pool
FROM per_pool_tx_raw
GROUP BY txid, effective_timestamp, utxo
),
tx_trades AS (
SELECT
txid,
effective_timestamp,
MIN(min_sequence) AS min_sequence,
SUM(signed_sats_pool) AS signed_sats,
SUM(signed_tokens_pool) AS signed_tokens,
SUM(volume_sats_pool) AS volume_sats,
SUM(volume_tokens_pool) AS volume_tokens
FROM per_pool_tx
GROUP BY txid, effective_timestamp
)
SELECT
effective_timestamp,
signed_sats,
signed_tokens,
volume_sats,
volume_tokens
FROM tx_trades
ORDER BY effective_timestamp ASC, min_sequence ASC;
GROUP BY phe.txid, phe.effective_timestamp
ORDER BY phe.effective_timestamp ASC, min_sequence ASC;
"#;
let rows = sqlx::query(sql)
.bind(token_blob)
@ -269,7 +243,7 @@ ORDER BY effective_timestamp ASC, min_sequence ASC;
Ok(rows
.into_iter()
.map(|r| (r.get(0), r.get(1), r.get(2), r.get(3), r.get(4)))
.map(|r| (r.get(0), r.get(1), r.get(2)))
.collect())
}
@ -282,43 +256,14 @@ async fn fetch_last_close_before(
timestamp_end: i64,
) -> Result<Option<f64>> {
let sql = r#"
WITH per_pool_tx_raw AS (
SELECT
phe.txid,
phe.effective_timestamp,
phe.utxo,
phe.sats_delta,
phe.token_delta,
phe.sequence
FROM pool_history_entry AS phe
WHERE phe.token_id = ?
SELECT
CAST(SUM(ABS(phe.sats_delta)) AS REAL) / CAST(SUM(ABS(phe.token_delta)) AS REAL) AS close_price
FROM pool_history_entry AS phe
WHERE phe.token_id = ?
AND phe.effective_timestamp < ?
),
per_pool_tx AS (
SELECT
txid,
effective_timestamp,
utxo,
MIN(sequence) AS min_sequence,
SUM(sats_delta) AS signed_sats_pool,
SUM(token_delta) AS signed_tokens_pool
FROM per_pool_tx_raw
GROUP BY txid, effective_timestamp, utxo
),
tx_trades AS (
SELECT
txid,
effective_timestamp,
MIN(min_sequence) AS min_sequence,
SUM(signed_sats_pool) AS signed_sats,
SUM(signed_tokens_pool) AS signed_tokens
FROM per_pool_tx
GROUP BY txid, effective_timestamp
)
SELECT ABS(CAST(signed_sats AS REAL) / CAST(signed_tokens AS REAL)) AS close_price
FROM tx_trades
WHERE signed_tokens != 0
ORDER BY effective_timestamp DESC, min_sequence DESC
GROUP BY phe.txid, phe.effective_timestamp
HAVING SUM(ABS(phe.token_delta)) != 0
ORDER BY phe.effective_timestamp DESC, MIN(phe.sequence) DESC
LIMIT 1
"#;
let row = sqlx::query(sql)

View file

@ -86,6 +86,168 @@ async fn insert_trade_at(
.unwrap();
}
/// Insert one transaction that touches several pools, each leg with its own
/// `(sats_delta, token_delta)`. Models a router/arbitrage transaction.
async fn insert_multileg_trade_at(
conn: &mut sqlx::pool::PoolConnection<sqlx::Sqlite>,
token: &TokenID,
txid_byte: u8,
ts: u64,
legs: &[(i64, i64)],
) {
let txid = Txid::from_byte_array([txid_byte; 32]);
let block = BlockHash::all_zeros();
insert_mempool_tx(&mut **conn, &txid, ts).await.unwrap();
insert_block_tx(&mut **conn, &txid, &block, ts as i64)
.await
.unwrap();
for (i, (sats_delta, token_delta)) in legs.iter().enumerate() {
let mut utxo_bytes = [txid_byte; 32];
utxo_bytes[0] = i as u8;
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(
&txid,
&utxo,
token,
sats_delta.unsigned_abs() * 10,
token_delta.abs() * 10,
&PubkeyHash::all_zeros(),
);
insert_utxo_funding(&mut **conn, &vec![cauldron.clone()], &txid)
.await
.unwrap();
pool::insert_pool_history_entry(
&mut **conn,
&pool_hash,
&cauldron,
Some(ts),
Some(ts),
*sats_delta,
*token_delta,
)
.await
.unwrap();
}
}
/// Regression for the netting artifact: a multi-pool arbitrage transaction that buys
/// from one pool and sells into another nets its token movement to almost nothing.
/// Dividing the signed sums produced a price no leg traded at — on mainnet token NWB
/// (tx 1E84F4E9…1916, 27 legs) that printed 30,792,599.5 sats/unit against legs that
/// actually executed between 0.288 and 0.335.
#[tokio::test]
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,
/// so 99.76% of mainnet prints — including the OLA supply-shock crash — are untouched.
#[tokio::test]
async fn test_single_direction_multileg_price_matches_net_ratio() {
let db = mock_db_pool(setup_db).await;
let token = TokenID::from_byte_array([0xC2; 32]);
let token_blob = token.to_blob();
let legs = [(150_000i64, -3_000i64), (50_000, -1_000), (99_000, -2_000)];
let mut conn = db.cauldron_w.acquire().await.unwrap();
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
.unwrap()
.expect("router transaction must price");
let signed_sats: i64 = legs.iter().map(|l| l.0).sum();
let signed_tokens: i64 = legs.iter().map(|l| l.1).sum();
let net_ratio = (signed_sats as f64 / signed_tokens as f64).abs();
assert!(
(price - net_ratio).abs() < f64::EPSILON,
"single-direction transactions must be unaffected: {price} vs {net_ratio}"
);
}
/// A transaction whose legs cancel exactly used to print nothing at all (the chart
/// carried the previous close). Its legs are real executions and now price normally.
#[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 mut conn = db.cauldron_w.acquire().await.unwrap();
insert_multileg_trade_at(
&mut conn,
&token,
0x23,
1000,
&[(-9_000, 1_000), (11_000, -1_000)],
)
.await;
let price = super::fetch_last_close_before(&db.cauldron_r, &token_blob, 2000)
.await
.unwrap()
.expect("zero-net transaction must still price from its legs");
// 20_000 gross sats over 2_000 gross tokens, between the 9 and 11 leg prices.
assert!((price - 10.0).abs() < f64::EPSILON, "got {price}");
}
/// 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;

View file

@ -3,9 +3,18 @@
// This software is licensed under the GNU Affero General Public License (AGPL), version 3.0 or later.
// A copy of the license can be found in the LICENSE file or at https://www.gnu.org/licenses/agpl-3.0.html
use crate::db::cauldron::config::{config_get, config_set};
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.
pub const OHLCV_VERSION: u32 = 2;
const OHLCV_VERSION_KEY: &str = "ohlcv_version";
pub async fn create_table(pool: &SqlitePool) {
sqlx::query(
"CREATE TABLE IF NOT EXISTS ohlcv_1h (
@ -26,6 +35,30 @@ pub async fn create_table(pool: &SqlitePool) {
.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")
@ -90,8 +123,6 @@ per_pool_tx AS (
(ts / 3600) * 3600 AS bucket_ts,
utxo,
MIN(sequence) AS min_sequence,
SUM(sats_delta) AS signed_sats,
SUM(token_delta) AS signed_tokens,
SUM(ABS(sats_delta)) AS vol_sats,
SUM(ABS(token_delta)) AS vol_tokens
FROM per_pool_tx_raw
@ -104,8 +135,6 @@ tx_trades AS (
ts,
bucket_ts,
MIN(min_sequence) AS min_sequence,
SUM(signed_sats) AS signed_sats,
SUM(signed_tokens) AS signed_tokens,
SUM(vol_sats) AS vol_sats,
SUM(vol_tokens) AS vol_tokens
FROM per_pool_tx
@ -115,11 +144,11 @@ priceable AS (
SELECT
token_id,
bucket_ts,
ABS(CAST(signed_sats AS REAL) / CAST(signed_tokens AS REAL)) AS price,
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 signed_tokens != 0
WHERE vol_tokens != 0
),
ohlc AS (
SELECT
@ -286,7 +315,7 @@ mod tests {
token_delta: i64,
) {
let blockhash = [0xAA_u8; 32];
sqlx::query("INSERT INTO tx (txid, blockhash, mtp_timestamp) VALUES (?, ?, ?)")
sqlx::query("INSERT OR IGNORE INTO tx (txid, blockhash, mtp_timestamp) VALUES (?, ?, ?)")
.bind(txid.as_slice())
.bind(blockhash.as_slice())
.bind(mtp_ts)
@ -446,4 +475,91 @@ mod tests {
.unwrap();
assert_eq!(n, 0, "mempool trades must not be materialised");
}
/// A multi-pool arbitrage transaction whose legs nearly cancel must materialise the
/// price its legs executed at, not the signed-net ratio.
#[tokio::test]
async fn test_rebuild_range_prices_arb_by_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_confirmed_trade(
&pool,
txid,
[0x02; 32],
token,
1727963400,
-446_491_239,
1_334_527_069,
)
.await;
insert_confirmed_trade(
&pool,
txid,
[0x04; 32],
token,
1727963400,
384_906_040,
-1_334_527_067,
)
.await;
rebuild_range(&pool, &pool, 1727960400, 1727964000)
.await
.unwrap();
let close: f64 = sqlx::query_scalar("SELECT close FROM ohlcv_1h WHERE token_id = ?")
.bind(token.as_slice())
.fetch_one(&pool)
.await
.unwrap();
let expected = 831_397_279.0 / 2_669_054_136.0;
assert!(
(close - expected).abs() < 1e-9,
"materialised close {close} should be the gross ratio {expected}"
);
}
/// 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");
}
}

View file

@ -419,6 +419,25 @@ async fn launch() -> _ {
// Ensure the OHLCV pre-aggregation table exists (safe on both new and existing DBs).
db::cauldron::ohlcv::create_table(&dbpool.cauldron_w).await;
// Discard buckets materialised under a superseded pricing rule.
let ohlcv_wiped =
match db::cauldron::ohlcv::migrate_if_stale(&dbpool.cauldron_r, &dbpool.cauldron_w).await {
Ok(wiped) => {
if wiped {
info!(
"ohlcv: cleared for rebuild at version {}; \
serving the raw path until the background task catches up",
db::cauldron::ohlcv::OHLCV_VERSION
);
}
wiped
}
Err(e) => {
warn!("ohlcv: version check failed, leaving table as-is: {e}");
false
}
};
// Bootstrap OhlcvState from whatever is already in the table (survives restarts).
let max_bucket_ts = db::cauldron::ohlcv::get_max_bucket_ts(&dbpool.cauldron_r)
.await
@ -431,7 +450,11 @@ async fn launch() -> _ {
// Synchronous post-IBD backfill: run the full ohlcv_1h materialisation before
// allowing metrics_cache and other background writers to start. We reuse the
// indexing_in_progress flag so metrics_cache backs off during this window.
{
//
// Skipped after a version wipe: the backfill runs before `rocket::build()` returns,
// so re-materialising all of history here would refuse connections for the whole
// rebuild rather than degrading to the (correct, slower) raw path.
if !ohlcv_wiped {
const BACKFILL_BATCH_SECS: i64 = 24 * 3600;
const BACKFILL_SAFETY_SECS: i64 = 3 * 3600;

View file

@ -664,10 +664,12 @@ async fn test_candle_carries_price_when_net_zero_tokens_but_has_volume() {
assert_eq!(c1["volume_tokens"].as_i64().unwrap(), 200);
assert_eq!(c1["transaction_count"].as_i64().unwrap(), 1);
// --- Candle #2 (net-zero tokens, carry-forward close) ---
// --- Candle #2 (legs cancel to zero net tokens) ---
let c2 = &candles[1];
assert_eq!(c2["time"].as_i64().unwrap(), (start + 600) as i64);
// Should carry previous close (50) because signed_tokens==0 but volume>0
// Both legs executed at 50, so the gross ratio 20_000/400 prices the tx at 50
// 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["low"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON);