Use last trade as the basis for the price outside chart instead of the last token/sats amount
This commit is contained in:
parent
d5fa393870
commit
89836789b2
1 changed files with 342 additions and 3 deletions
|
|
@ -290,6 +290,63 @@ ORDER BY effective_timestamp ASC, min_sequence ASC;
|
|||
.collect())
|
||||
}
|
||||
|
||||
/// Returns the close price of the most recent priceable trade strictly before
|
||||
/// `timestamp_end`, using the same per-tx aggregation as `fetch_raw_trades`.
|
||||
/// Returns `None` when no prior trade exists (new token, no history).
|
||||
async fn fetch_last_close_before(
|
||||
pool: &SqlitePool,
|
||||
token_blob: &[u8],
|
||||
timestamp_end: i64,
|
||||
) -> Result<Option<f64>> {
|
||||
let sql = r#"
|
||||
WITH per_pool_tx_raw AS (
|
||||
SELECT
|
||||
phe.txid,
|
||||
phe.effective_timestamp,
|
||||
phe.utxo,
|
||||
phe.sats_delta,
|
||||
phe.token_delta,
|
||||
phe.sequence
|
||||
FROM pool_history_entry AS phe
|
||||
WHERE phe.token_id = ?
|
||||
AND phe.effective_timestamp < ?
|
||||
),
|
||||
per_pool_tx AS (
|
||||
SELECT
|
||||
txid,
|
||||
effective_timestamp,
|
||||
utxo,
|
||||
MIN(sequence) AS min_sequence,
|
||||
SUM(sats_delta) AS signed_sats_pool,
|
||||
SUM(token_delta) AS signed_tokens_pool
|
||||
FROM per_pool_tx_raw
|
||||
GROUP BY txid, effective_timestamp, utxo
|
||||
),
|
||||
tx_trades AS (
|
||||
SELECT
|
||||
txid,
|
||||
effective_timestamp,
|
||||
MIN(min_sequence) AS min_sequence,
|
||||
SUM(signed_sats_pool) AS signed_sats,
|
||||
SUM(signed_tokens_pool) AS signed_tokens
|
||||
FROM per_pool_tx
|
||||
GROUP BY txid, effective_timestamp
|
||||
)
|
||||
SELECT ABS(CAST(signed_sats AS REAL) / CAST(signed_tokens AS REAL)) AS close_price
|
||||
FROM tx_trades
|
||||
WHERE signed_tokens != 0
|
||||
ORDER BY effective_timestamp DESC, min_sequence DESC
|
||||
LIMIT 1
|
||||
"#;
|
||||
let row = sqlx::query(sql)
|
||||
.bind(token_blob)
|
||||
.bind(timestamp_end)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|r| r.get::<f64, _>(0)))
|
||||
}
|
||||
|
||||
/// `ohlcv_materialized_end`: exclusive upper bound of what is in `ohlcv_1h`.
|
||||
/// Pass 0 to always use the raw CTE path.
|
||||
pub async fn candlesticks(
|
||||
|
|
@ -306,6 +363,12 @@ pub async fn candlesticks(
|
|||
|
||||
let token_blob = display_hex_to_blob::<TokenID>(token_id)?;
|
||||
|
||||
// Seed gap-fill with the last known close price before this window so that
|
||||
// switching between timeframes (e.g. 1W vs 1M) produces consistent prices
|
||||
// for any overlapping period.
|
||||
let seed_close = fetch_last_close_before(pool, &token_blob, timestamp_start).await?;
|
||||
let seed_found = seed_close.is_some();
|
||||
|
||||
// Fast path: use pre-materialised ohlcv_1h when step_size is exactly 1 hour,
|
||||
// ohlcv covers at least part of the range, AND the start is hour-aligned.
|
||||
// ohlcv_1h buckets are always aligned to multiples of 3600, so a non-aligned
|
||||
|
|
@ -318,8 +381,13 @@ pub async fn candlesticks(
|
|||
let ohlcv_rows =
|
||||
ohlcv::get_active_candles(pool, &token_blob, timestamp_start, ohlcv_end).await?;
|
||||
|
||||
let (mut result, found_first, last_close) =
|
||||
fill_ohlcv_candles(ohlcv_rows, timestamp_start, ohlcv_end, false, None);
|
||||
let (mut result, found_first, last_close) = fill_ohlcv_candles(
|
||||
ohlcv_rows,
|
||||
timestamp_start,
|
||||
ohlcv_end,
|
||||
seed_found,
|
||||
seed_close,
|
||||
);
|
||||
|
||||
if ohlcv_end < timestamp_end {
|
||||
// Tail: query raw for [ohlcv_end, timestamp_end) and append.
|
||||
|
|
@ -355,7 +423,8 @@ pub async fn candlesticks(
|
|||
|
||||
let all_trades = fetch_raw_trades(pool, &token_blob, timestamp_start, timestamp_end).await?;
|
||||
|
||||
let (result, _, _) = aggregate_raw_trades(&all_trades, intervals, step_size, false, None);
|
||||
let (result, _, _) =
|
||||
aggregate_raw_trades(&all_trades, intervals, step_size, seed_found, seed_close);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
|
@ -478,6 +547,7 @@ pub async fn price_candlesticks(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::db::blob::ToBlob;
|
||||
use crate::db::cauldron::{
|
||||
ohlcv,
|
||||
pool::{self, dummy_init_seq, insert_new_pool},
|
||||
|
|
@ -488,6 +558,7 @@ mod tests {
|
|||
use crate::OhlcvState;
|
||||
|
||||
use crate::timeutil::time_now;
|
||||
use bitcoin_hashes::hex::ToHex;
|
||||
use bitcoin_hashes::Hash;
|
||||
use bitcoincash::{BlockHash, PubkeyHash, TokenID, Txid};
|
||||
use riftenlabs_defi::{cauldron::ParsedContract, chainutil::OutPointHash};
|
||||
|
|
@ -1315,4 +1386,272 @@ mod tests {
|
|||
assert_eq!(candles[1]["time"].as_i64().unwrap(), 1727966900);
|
||||
assert_eq!(candles[1]["transaction_count"].as_i64().unwrap(), 0);
|
||||
}
|
||||
|
||||
// ── fetch_last_close_before + gap-fill seeding tests ───────────────────
|
||||
|
||||
/// Insert a single confirmed trade for a token.
|
||||
/// `txid_byte` is used to derive unique txid/utxo hashes.
|
||||
async fn insert_trade_at(
|
||||
conn: &mut sqlx::pool::PoolConnection<sqlx::Sqlite>,
|
||||
token: &TokenID,
|
||||
txid_byte: u8,
|
||||
ts: u64,
|
||||
sats_delta: i64,
|
||||
token_delta: i64,
|
||||
) {
|
||||
let txid = Txid::from_inner([txid_byte; 32]);
|
||||
let utxo = OutPointHash::from_inner([txid_byte; 32]);
|
||||
let pool_hash = OutPointHash::from_inner([txid_byte.wrapping_add(0x80); 32]);
|
||||
let block = BlockHash::all_zeros();
|
||||
|
||||
let cauldron = dummy_cauldron(
|
||||
&txid,
|
||||
&utxo,
|
||||
token,
|
||||
sats_delta.unsigned_abs(),
|
||||
token_delta,
|
||||
&PubkeyHash::all_zeros(),
|
||||
);
|
||||
|
||||
insert_utxo_funding(&mut **conn, &vec![cauldron.clone()], &txid)
|
||||
.await
|
||||
.unwrap();
|
||||
insert_mempool_tx(&mut **conn, &txid, ts).await.unwrap();
|
||||
insert_block_tx(&mut **conn, &txid, &block, ts as i64)
|
||||
.await
|
||||
.unwrap();
|
||||
pool::insert_pool_history_entry(
|
||||
&mut **conn,
|
||||
&pool_hash,
|
||||
&cauldron,
|
||||
Some(ts),
|
||||
Some(ts),
|
||||
sats_delta,
|
||||
token_delta,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
async fn setup_seed_db(pool: sqlx::SqlitePool) {
|
||||
utxo_funding::create_table(&pool).await;
|
||||
tx::create_table(&pool).await;
|
||||
pool::create_table(&pool).await;
|
||||
ohlcv::create_table(&pool).await;
|
||||
dummy_init_seq();
|
||||
}
|
||||
|
||||
/// fetch_last_close_before returns None when there are no trades at all.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_last_close_before_no_trades() {
|
||||
let db = mock_db_pool(setup_seed_db).await;
|
||||
let token = TokenID::from_inner([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());
|
||||
}
|
||||
|
||||
/// fetch_last_close_before returns None when all trades are after timestamp_end.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_last_close_before_only_future_trades() {
|
||||
let db = mock_db_pool(setup_seed_db).await;
|
||||
let token = TokenID::from_inner([0xAB; 32]);
|
||||
let token_blob = token.to_blob();
|
||||
|
||||
let mut conn = db.cauldron_w.acquire().await.unwrap();
|
||||
// Trade at ts=2000, query for ts < 1000 → None
|
||||
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());
|
||||
}
|
||||
|
||||
/// fetch_last_close_before returns the most recent trade before the cutoff,
|
||||
/// not earlier ones, and computes the correct price.
|
||||
#[tokio::test]
|
||||
async fn test_fetch_last_close_before_returns_most_recent() {
|
||||
let db = mock_db_pool(setup_seed_db).await;
|
||||
let token = TokenID::from_inner([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);
|
||||
}
|
||||
|
||||
/// Raw path: when the window has no trades but there is a prior trade,
|
||||
/// all candles in the window are flat gap-fill at the prior close price.
|
||||
#[rocket::async_test]
|
||||
async fn test_raw_path_seeded_gap_fill_no_in_window_trades() {
|
||||
let token = TokenID::from_inner([0xBA; 32]);
|
||||
|
||||
let token_copy = token;
|
||||
let db = mock_db_pool(move |pool: sqlx::SqlitePool| async move {
|
||||
setup_seed_db(pool.clone()).await;
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
// price = 100_000 / 2_000 = 50 — BEFORE the query window
|
||||
insert_trade_at(&mut conn, &token_copy, 0x10, 1_000, 100_000, 2_000).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
let rocket = rocket::build()
|
||||
.manage(db)
|
||||
.manage(no_ohlcv())
|
||||
.mount("/api", routes![super::price_candlesticks]);
|
||||
let client = Client::tracked(rocket).await.unwrap();
|
||||
|
||||
// Window: [2000, 3000), step=500 — no in-window trades
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"/api/price/{}/candlesticks?start=2000&end=3000&stepsize=500",
|
||||
token.to_hex()
|
||||
))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), Status::Ok);
|
||||
let body = resp.into_string().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
let candles = json["candlesticks"].as_array().unwrap();
|
||||
|
||||
assert_eq!(candles.len(), 2, "both intervals should be gap-filled");
|
||||
for c in candles {
|
||||
assert_eq!(c["transaction_count"].as_i64().unwrap(), 0);
|
||||
assert!(
|
||||
(c["close"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON,
|
||||
"gap-fill should carry prior close (50), got {}",
|
||||
c["close"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Raw path: pre-window seed fills the gap before the first in-window trade,
|
||||
/// then real candle and post-trade gap-fill use the in-window close.
|
||||
#[rocket::async_test]
|
||||
async fn test_raw_path_seeded_gap_fill_then_in_window_trade() {
|
||||
let token = TokenID::from_inner([0xBB; 32]);
|
||||
|
||||
let token_copy = token;
|
||||
let db = mock_db_pool(move |pool: sqlx::SqlitePool| async move {
|
||||
setup_seed_db(pool.clone()).await;
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
// Pre-window trade: price = 50
|
||||
insert_trade_at(&mut conn, &token_copy, 0x20, 1_000, 100_000, 2_000).await;
|
||||
// In-window trade at ts=2500: price = 150_000 / 2_000 = 75
|
||||
insert_trade_at(&mut conn, &token_copy, 0x21, 2_500, 150_000, 2_000).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
let rocket = rocket::build()
|
||||
.manage(db)
|
||||
.manage(no_ohlcv())
|
||||
.mount("/api", routes![super::price_candlesticks]);
|
||||
let client = Client::tracked(rocket).await.unwrap();
|
||||
|
||||
// Window [2000, 3500), step=500 → intervals: [2000,2500), [2500,3000), [3000,3500)
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"/api/price/{}/candlesticks?start=2000&end=3500&stepsize=500",
|
||||
token.to_hex()
|
||||
))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), Status::Ok);
|
||||
let body = resp.into_string().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
let candles = json["candlesticks"].as_array().unwrap();
|
||||
|
||||
assert_eq!(candles.len(), 3);
|
||||
|
||||
// First interval: gap-fill seeded from pre-window close (50)
|
||||
assert_eq!(candles[0]["time"].as_i64().unwrap(), 2000);
|
||||
assert_eq!(candles[0]["transaction_count"].as_i64().unwrap(), 0);
|
||||
assert!(
|
||||
(candles[0]["close"].as_f64().unwrap() - 50.0).abs() < f64::EPSILON,
|
||||
"seed gap-fill should be 50, got {}",
|
||||
candles[0]["close"]
|
||||
);
|
||||
|
||||
// Second interval: real in-window trade (75)
|
||||
assert_eq!(candles[1]["time"].as_i64().unwrap(), 2500);
|
||||
assert_eq!(candles[1]["transaction_count"].as_i64().unwrap(), 1);
|
||||
assert!(
|
||||
(candles[1]["close"].as_f64().unwrap() - 75.0).abs() < f64::EPSILON,
|
||||
"real candle close should be 75, got {}",
|
||||
candles[1]["close"]
|
||||
);
|
||||
|
||||
// Third interval: gap-fill from in-window close (75), not from seed (50)
|
||||
assert_eq!(candles[2]["time"].as_i64().unwrap(), 3000);
|
||||
assert_eq!(candles[2]["transaction_count"].as_i64().unwrap(), 0);
|
||||
assert!(
|
||||
(candles[2]["close"].as_f64().unwrap() - 75.0).abs() < f64::EPSILON,
|
||||
"post-trade gap-fill should follow in-window close (75), got {}",
|
||||
candles[2]["close"]
|
||||
);
|
||||
}
|
||||
|
||||
/// Without a pre-window trade, no candles appear before the first in-window trade.
|
||||
/// This confirms the seed is only active when a prior trade actually exists.
|
||||
#[rocket::async_test]
|
||||
async fn test_raw_path_no_seed_no_prefill() {
|
||||
let token = TokenID::from_inner([0xBC; 32]);
|
||||
|
||||
let token_copy = token;
|
||||
let db = mock_db_pool(move |pool: sqlx::SqlitePool| async move {
|
||||
setup_seed_db(pool.clone()).await;
|
||||
let mut conn = pool.acquire().await.unwrap();
|
||||
// Only an in-window trade at ts=2500: price=75 — no pre-window trade
|
||||
insert_trade_at(&mut conn, &token_copy, 0x30, 2_500, 150_000, 2_000).await;
|
||||
})
|
||||
.await;
|
||||
|
||||
let rocket = rocket::build()
|
||||
.manage(db)
|
||||
.manage(no_ohlcv())
|
||||
.mount("/api", routes![super::price_candlesticks]);
|
||||
let client = Client::tracked(rocket).await.unwrap();
|
||||
|
||||
let resp = client
|
||||
.get(format!(
|
||||
"/api/price/{}/candlesticks?start=2000&end=3500&stepsize=500",
|
||||
token.to_hex()
|
||||
))
|
||||
.dispatch()
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), Status::Ok);
|
||||
let body = resp.into_string().await.unwrap();
|
||||
let json: serde_json::Value = serde_json::from_str(&body).unwrap();
|
||||
let candles = json["candlesticks"].as_array().unwrap();
|
||||
|
||||
// [2000,2500) must be absent — no seed, no prior close to gap-fill from
|
||||
assert_eq!(
|
||||
candles.len(),
|
||||
2,
|
||||
"only two candles expected (real + post-trade gap-fill)"
|
||||
);
|
||||
assert_eq!(
|
||||
candles[0]["time"].as_i64().unwrap(),
|
||||
2500,
|
||||
"first candle must be the real trade"
|
||||
);
|
||||
assert_eq!(candles[0]["transaction_count"].as_i64().unwrap(), 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue