Stage 2 Phase 1: Per-leg policy core for manipulation-resistant candlestick pricing
New module: candlestick/policy.rs implements the core state machine that judges and applies legs for acceptance based on deviation from a reference price and qualification credit. Key components: - Leg: per-pool per-transaction change (sats_delta, token_delta, post-state reserves) - JudgeResult: verdict on whether a leg is accepted into OHLC - Policy: stateful fold that maintains pool reserves, qualification credit, and a min-depth-weighted median reference price Qualification rules (two-tier): - Tier 1: dev <= F (F=5) always accepted - Tier 2: dev > F but summed_credit >= q*largest_credit (q=5%) also accepted - Everything else is muted (volume still counted) Reference computation: - R = min-depth-weighted median of pool spot ratios - Updated only on reserve events (swaps, creations, withdrawals), never on prints - Depth = min(sats, tokens * R_prev) to zero-weight lopsided pools - Avoids ratchet-walking and qualifies token-heavy reseeds (OLA-like) Tests: 5 passing - unpriceable legs - first leg (no reference yet) - tier 1 acceptance (dev within F) - tier 1 rejection (dev > F, no credit) - credit seeding on accepted prints Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
283c0c3e2c
commit
2ba379828d
2 changed files with 351 additions and 0 deletions
|
|
@ -356,5 +356,7 @@ pub async fn candlesticks(
|
|||
Ok(result)
|
||||
}
|
||||
|
||||
pub mod policy;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
|
|
|||
349
src/db/cauldron/candlestick/policy.rs
Normal file
349
src/db/cauldron/candlestick/policy.rs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
// 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;
|
||||
|
||||
/// A leg of a transaction: one pool's change in a single transaction.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Leg {
|
||||
pub txid: [u8; 32],
|
||||
pub pool: [u8; 32],
|
||||
pub ts: i64,
|
||||
pub sequence: i64,
|
||||
pub sats_delta: i64, // sats change at this pool
|
||||
pub token_delta: i64, // token change at this pool
|
||||
pub sats: u64, // post-change sats (reserves)
|
||||
pub token_amount: u64, // post-change token_amount (reserves)
|
||||
}
|
||||
|
||||
/// Outcome of judging a leg for acceptance.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct JudgeResult {
|
||||
pub price: Option<f64>, // None if token_delta == 0
|
||||
pub accepted: bool, // whether this leg is accepted into OHLC
|
||||
pub reason: String, // debug: why accepted or muted
|
||||
}
|
||||
|
||||
/// Per-pool state: reserves and credit.
|
||||
#[derive(Clone, Debug)]
|
||||
struct PoolState {
|
||||
sequence: i64,
|
||||
sats: u64,
|
||||
token_amount: u64,
|
||||
credit: u64, // min-depth at last accepted print, seeded at creation
|
||||
}
|
||||
|
||||
/// The policy engine: stateful fold over legs.
|
||||
///
|
||||
/// Maintains:
|
||||
/// - Pool reserves (sats, token_amount, sequence)
|
||||
/// - Qualification credit (min-depth at last accepted print)
|
||||
/// - Reference R (min-depth-weighted median of pool spots)
|
||||
pub struct Policy {
|
||||
pools: HashMap<[u8; 32], PoolState>,
|
||||
reference: Option<f64>, // min-depth-weighted median of pool spots
|
||||
params: GuardParams,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct GuardParams {
|
||||
pub max_deviation_factor: f64, // F: mute if dev > F AND not exempted
|
||||
pub min_share_fraction: f64, // q: exemption if summed_credit >= q * largest_credit
|
||||
}
|
||||
|
||||
impl Policy {
|
||||
pub fn new(params: GuardParams) -> Self {
|
||||
Self {
|
||||
pools: HashMap::new(),
|
||||
reference: None,
|
||||
params,
|
||||
}
|
||||
}
|
||||
|
||||
/// Judge a leg for acceptance. Must be called before apply().
|
||||
pub fn judge(&mut self, leg: &Leg) -> JudgeResult {
|
||||
// Leg is unpriceable if no token movement.
|
||||
if leg.token_delta == 0 {
|
||||
return JudgeResult {
|
||||
price: None,
|
||||
accepted: false,
|
||||
reason: "no token movement".into(),
|
||||
};
|
||||
}
|
||||
|
||||
let price = (leg.sats_delta.unsigned_abs() as f64) / (leg.token_delta.abs() as f64);
|
||||
|
||||
// If no reference yet, accept (new token, first pool).
|
||||
let Some(ref_price) = self.reference else {
|
||||
return JudgeResult {
|
||||
price: Some(price),
|
||||
accepted: true,
|
||||
reason: "no reference yet".into(),
|
||||
};
|
||||
};
|
||||
|
||||
// Check deviation from reference.
|
||||
let dev = (price / ref_price).max(ref_price / price);
|
||||
|
||||
// Tier 1: deviation within F always prints.
|
||||
if dev <= self.params.max_deviation_factor {
|
||||
return JudgeResult {
|
||||
price: Some(price),
|
||||
accepted: true,
|
||||
reason: format!("dev {:.2} <= F {:.1}", dev, self.params.max_deviation_factor),
|
||||
};
|
||||
}
|
||||
|
||||
// Tier 2: exemption if this leg's pool credit is high enough.
|
||||
let pool_credit = self.pools.get(&leg.pool).map(|ps| ps.credit).unwrap_or(0);
|
||||
let largest_credit = self.pools.values().map(|ps| ps.credit).max().unwrap_or(0);
|
||||
let threshold_credit = ((largest_credit as f64) * self.params.min_share_fraction) as u64;
|
||||
|
||||
if largest_credit > 0 && pool_credit >= threshold_credit {
|
||||
return JudgeResult {
|
||||
price: Some(price),
|
||||
accepted: true,
|
||||
reason: format!(
|
||||
"dev {:.2} > F but credit {}/{} >= {:.0}%",
|
||||
dev,
|
||||
pool_credit,
|
||||
largest_credit,
|
||||
self.params.min_share_fraction * 100.0
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Muted: deviation too high and credit too low.
|
||||
JudgeResult {
|
||||
price: Some(price),
|
||||
accepted: false,
|
||||
reason: format!(
|
||||
"dev {:.2} > F {:.1} AND credit {}/{} < {:.0}%",
|
||||
dev,
|
||||
self.params.max_deviation_factor,
|
||||
pool_credit,
|
||||
largest_credit,
|
||||
self.params.min_share_fraction * 100.0
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a leg: update pool reserves, credit, and reference.
|
||||
/// Must be called after judge(), regardless of acceptance.
|
||||
pub fn apply(&mut self, leg: &Leg, accepted: bool) {
|
||||
// Compute min-depth before borrowing pools (to avoid borrow checker issues).
|
||||
let new_credit = if accepted && leg.token_delta != 0 {
|
||||
let ref_price = self.reference.unwrap_or(1.0);
|
||||
let token_valued = ((leg.token_amount as f64) * ref_price).ceil() as u64;
|
||||
leg.sats.min(token_valued)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Ensure pool state exists.
|
||||
let state = self
|
||||
.pools
|
||||
.entry(leg.pool)
|
||||
.or_insert(PoolState {
|
||||
sequence: leg.sequence,
|
||||
sats: leg.sats,
|
||||
token_amount: leg.token_amount,
|
||||
credit: 0,
|
||||
});
|
||||
|
||||
// Update reserves only if sequence is monotonic (later than what we've seen).
|
||||
if leg.sequence > state.sequence {
|
||||
state.sequence = leg.sequence;
|
||||
state.sats = leg.sats;
|
||||
state.token_amount = leg.token_amount;
|
||||
|
||||
// Update credit on accepted prints.
|
||||
if new_credit > 0 {
|
||||
state.credit = new_credit;
|
||||
}
|
||||
}
|
||||
|
||||
// Recompute reference from all pools' reserves.
|
||||
self.reference = self.compute_reference();
|
||||
}
|
||||
|
||||
/// Compute min-depth for a pool: min(sats, tokens * reference).
|
||||
/// If no reference yet, use sats (will get updated once reference exists).
|
||||
fn min_depth(&self, sats: u64, token_amount: u64) -> u64 {
|
||||
let ref_price = self.reference.unwrap_or(1.0);
|
||||
let token_valued_at_ref = ((token_amount as f64) * ref_price).ceil() as u64;
|
||||
sats.min(token_valued_at_ref)
|
||||
}
|
||||
|
||||
/// Compute the min-depth-weighted median of pool spot ratios.
|
||||
fn compute_reference(&self) -> Option<f64> {
|
||||
if self.pools.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Filter pools with token_amount >= 10 (avoid divide-by-zero and dust).
|
||||
let mut entries: Vec<(f64, u64)> = self
|
||||
.pools
|
||||
.values()
|
||||
.filter(|ps| ps.token_amount >= 10)
|
||||
.map(|ps| {
|
||||
let spot_ratio = (ps.sats as f64) / (ps.token_amount as f64);
|
||||
let weight = self.min_depth(ps.sats, ps.token_amount);
|
||||
(spot_ratio, weight)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Sort by spot ratio ascending.
|
||||
entries.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
|
||||
|
||||
// Compute total weight.
|
||||
let total_weight: u128 = entries.iter().map(|(_, w)| *w as u128).sum();
|
||||
|
||||
// Find the ratio at the 50th percentile.
|
||||
let half_weight = total_weight / 2;
|
||||
let mut cumulative: u128 = 0;
|
||||
for (spot_ratio, weight) in &entries {
|
||||
cumulative += *weight as u128;
|
||||
if cumulative >= half_weight {
|
||||
return Some(*spot_ratio);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: last ratio (shouldn't reach here if weights are positive).
|
||||
entries.last().map(|(spot_ratio, _)| *spot_ratio)
|
||||
}
|
||||
|
||||
/// Get current reference price (for testing/debugging).
|
||||
pub fn reference(&self) -> Option<f64> {
|
||||
self.reference
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn leg(
|
||||
pool: u8,
|
||||
ts: i64,
|
||||
seq: i64,
|
||||
sats_delta: i64,
|
||||
token_delta: i64,
|
||||
sats: u64,
|
||||
token_amount: u64,
|
||||
) -> Leg {
|
||||
Leg {
|
||||
txid: [0u8; 32],
|
||||
pool: [pool; 32],
|
||||
ts,
|
||||
sequence: seq,
|
||||
sats_delta,
|
||||
token_delta,
|
||||
sats,
|
||||
token_amount,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unpriceable_leg() {
|
||||
let mut policy = Policy::new(GuardParams {
|
||||
max_deviation_factor: 5.0,
|
||||
min_share_fraction: 0.05,
|
||||
});
|
||||
|
||||
let l = leg(1, 1000, 1, 100, 0, 1000, 1000);
|
||||
let judge = policy.judge(&l);
|
||||
assert!(!judge.accepted);
|
||||
assert!(judge.price.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_first_leg_always_accepts() {
|
||||
let mut policy = Policy::new(GuardParams {
|
||||
max_deviation_factor: 5.0,
|
||||
min_share_fraction: 0.05,
|
||||
});
|
||||
|
||||
let l = leg(1, 1000, 1, 100, 1000, 1100, 2000);
|
||||
let judge = policy.judge(&l);
|
||||
assert!(judge.accepted, "{}", judge.reason);
|
||||
assert!(judge.price.is_some());
|
||||
|
||||
policy.apply(&l, judge.accepted);
|
||||
assert!(policy.reference().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_second_leg_within_deviation() {
|
||||
let mut policy = Policy::new(GuardParams {
|
||||
max_deviation_factor: 5.0,
|
||||
min_share_fraction: 0.05,
|
||||
});
|
||||
|
||||
let l1 = leg(1, 1000, 1, 1000, 1000, 2000, 2000);
|
||||
let j1 = policy.judge(&l1);
|
||||
policy.apply(&l1, j1.accepted);
|
||||
|
||||
// Second leg at similar price (ref should be 1.0).
|
||||
let l2 = leg(2, 1000, 2, 950, 1000, 2000, 2000);
|
||||
let j2 = policy.judge(&l2);
|
||||
assert!(j2.accepted, "{}", j2.reason);
|
||||
|
||||
policy.apply(&l2, j2.accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_second_leg_far_from_reference_muted() {
|
||||
let mut policy = Policy::new(GuardParams {
|
||||
max_deviation_factor: 5.0,
|
||||
min_share_fraction: 0.05,
|
||||
});
|
||||
|
||||
let l1 = leg(1, 1000, 1, 1000, 1000, 2000, 2000);
|
||||
let j1 = policy.judge(&l1);
|
||||
policy.apply(&l1, j1.accepted);
|
||||
|
||||
// Second leg at 100x price deviation, no credit (new pool).
|
||||
let l2 = leg(2, 1000, 1, 100000, 1000, 101000, 1001);
|
||||
let j2 = policy.judge(&l2);
|
||||
assert!(!j2.accepted, "{}", j2.reason);
|
||||
assert!(j2.price.is_some());
|
||||
|
||||
policy.apply(&l2, j2.accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credit_seeding_on_accepted_print() {
|
||||
let mut policy = Policy::new(GuardParams {
|
||||
max_deviation_factor: 5.0,
|
||||
min_share_fraction: 0.05,
|
||||
});
|
||||
|
||||
// Pool 1: large, accepts at ref
|
||||
let l1 = leg(1, 1000, 1, 10000, 1000, 11000, 2000);
|
||||
let j1 = policy.judge(&l1);
|
||||
policy.apply(&l1, j1.accepted);
|
||||
|
||||
// Pool 2: smaller, deviates but has no credit yet (new pool)
|
||||
let l2 = leg(2, 1000, 1, 10000, 1000, 11000, 1000);
|
||||
let j2 = policy.judge(&l2);
|
||||
policy.apply(&l2, j2.accepted);
|
||||
|
||||
// Pool 2 should now have credit from its accepted print.
|
||||
// A later muted print won't restore credit.
|
||||
let l2_muted = leg(2, 1001, 2, 100000, 1000, 101000, 1001);
|
||||
let j2_muted = policy.judge(&l2_muted);
|
||||
assert!(!j2_muted.accepted);
|
||||
policy.apply(&l2_muted, j2_muted.accepted);
|
||||
|
||||
// Verify pool 2's credit didn't rise.
|
||||
let pool2_credit = policy.pools.get(&[2u8; 32]).map(|ps| ps.credit);
|
||||
assert!(pool2_credit.is_some());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue