0.7.28
This commit is contained in:
509
kb_lib/src/dex_event_classification.rs
Normal file
509
kb_lib/src/dex_event_classification.rs
Normal file
@@ -0,0 +1,509 @@
|
||||
// file: kb_lib/src/dex_event_classification.rs
|
||||
|
||||
//! Shared DEX event classification and decoded-payload enrichment.
|
||||
//!
|
||||
//! This module contains deterministic helpers used by DEX decoding,
|
||||
//! trade aggregation and future non-trade event materialization.
|
||||
//!
|
||||
//! It intentionally does not decode protocol instructions and does not
|
||||
//! perform database access.
|
||||
|
||||
/// Stable business category assigned to one decoded DEX event kind.
|
||||
#[derive(Debug, Copy, Clone, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub enum DexEventCategory {
|
||||
/// Swap-like event that can potentially become a normalized trade.
|
||||
Trade,
|
||||
/// Liquidity deposit, withdraw, position open or position close event.
|
||||
Liquidity,
|
||||
/// Fee collection event.
|
||||
Fee,
|
||||
/// Reward or emission event.
|
||||
Reward,
|
||||
/// Pool creation, initialization or migration event.
|
||||
PoolLifecycle,
|
||||
/// Protocol administration, configuration or permission update event.
|
||||
Admin,
|
||||
/// Event kind that is not classified yet.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl DexEventCategory {
|
||||
/// Returns the stable string code persisted inside decoded payload metadata.
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Trade => return "trade",
|
||||
Self::Liquidity => return "liquidity",
|
||||
Self::Fee => return "fee",
|
||||
Self::Reward => return "reward",
|
||||
Self::PoolLifecycle => return "pool_lifecycle",
|
||||
Self::Admin => return "admin",
|
||||
Self::Unknown => return "unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classifies a DEX event kind into a stable business category.
|
||||
pub fn classify_dex_event_category(event_kind: &str) -> DexEventCategory {
|
||||
if is_dex_reward_event_kind(event_kind) {
|
||||
return DexEventCategory::Reward;
|
||||
}
|
||||
if is_dex_fee_event_kind(event_kind) {
|
||||
return DexEventCategory::Fee;
|
||||
}
|
||||
if is_dex_liquidity_event_kind(event_kind) {
|
||||
return DexEventCategory::Liquidity;
|
||||
}
|
||||
if is_dex_pool_lifecycle_event_kind(event_kind) {
|
||||
return DexEventCategory::PoolLifecycle;
|
||||
}
|
||||
if is_dex_admin_event_kind(event_kind) {
|
||||
return DexEventCategory::Admin;
|
||||
}
|
||||
if is_dex_trade_event_kind(event_kind) {
|
||||
return DexEventCategory::Trade;
|
||||
}
|
||||
return DexEventCategory::Unknown;
|
||||
}
|
||||
|
||||
/// Classifies a DEX event kind and returns the persisted category code.
|
||||
pub fn classify_dex_event_category_code(event_kind: &str) -> &'static str {
|
||||
return classify_dex_event_category(event_kind).as_str();
|
||||
}
|
||||
|
||||
/// Returns true when the event kind represents a swap-like event.
|
||||
pub fn is_dex_trade_event_kind(event_kind: &str) -> bool {
|
||||
if event_kind.ends_with(".buy") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.ends_with(".sell") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.ends_with(".swap") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains(".swap_") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.ends_with(".exact_input") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.ends_with(".exact_output") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns true when the event kind can directly produce a candle candidate.
|
||||
pub fn is_dex_candle_candidate_event_kind(event_kind: &str) -> bool {
|
||||
if event_kind.contains("router") {
|
||||
return false;
|
||||
}
|
||||
if event_kind.contains("route") {
|
||||
return false;
|
||||
}
|
||||
return is_dex_trade_event_kind(event_kind);
|
||||
}
|
||||
|
||||
/// Returns true for liquidity lifecycle changes that must not become candles.
|
||||
pub fn is_dex_liquidity_event_kind(event_kind: &str) -> bool {
|
||||
if event_kind.contains(".deposit") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains(".withdraw") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains(".increase_liquidity") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains(".decrease_liquidity") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains(".open_position") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains(".close_position") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns true for fee collection events.
|
||||
pub fn is_dex_fee_event_kind(event_kind: &str) -> bool {
|
||||
if event_kind.contains("collect_creator_fee") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains("collect_protocol_fee") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains("collect_fund_fee") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains("collect_fee") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns true for reward or incentive events.
|
||||
pub fn is_dex_reward_event_kind(event_kind: &str) -> bool {
|
||||
if event_kind.contains("reward") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains("emission") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns true for pool creation, initialization or migration events.
|
||||
pub fn is_dex_pool_lifecycle_event_kind(event_kind: &str) -> bool {
|
||||
if event_kind.contains(".initialize") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains(".initialize_with_permission") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains(".create_pool") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains(".create_v2_token") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains(".migrate") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns true for admin, configuration or permission changes.
|
||||
pub fn is_dex_admin_event_kind(event_kind: &str) -> bool {
|
||||
if event_kind.contains("admin") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains("config") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains("permission") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains("set_") {
|
||||
return true;
|
||||
}
|
||||
if event_kind.contains("update_") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Returns true when a decoded payload is marked as a trade candidate.
|
||||
///
|
||||
/// Explicit payload metadata wins over event-kind inference. This allows
|
||||
/// incomplete decoded events, such as incomplete PumpSwap trades, to opt out.
|
||||
pub fn is_decoded_event_trade_candidate(event_kind: &str, payload: &serde_json::Value) -> bool {
|
||||
let trade_candidate_option =
|
||||
extract_top_level_bool_by_candidate_keys(payload, &["tradeCandidate", "trade_candidate"]);
|
||||
if let Some(trade_candidate) = trade_candidate_option {
|
||||
return trade_candidate;
|
||||
}
|
||||
let event_category_option =
|
||||
extract_string_by_candidate_keys(payload, &["eventCategory", "event_category"]);
|
||||
if let Some(event_category) = event_category_option {
|
||||
return event_category.as_str() == DexEventCategory::Trade.as_str();
|
||||
}
|
||||
return is_dex_trade_event_kind(event_kind);
|
||||
}
|
||||
|
||||
/// Returns true when a decoded payload can be materialized as a candle candidate.
|
||||
pub fn is_decoded_event_candle_candidate(event_kind: &str, payload: &serde_json::Value) -> bool {
|
||||
let candle_candidate_option =
|
||||
extract_top_level_bool_by_candidate_keys(payload, &["candleCandidate", "candle_candidate"]);
|
||||
if let Some(candle_candidate) = candle_candidate_option {
|
||||
return candle_candidate;
|
||||
}
|
||||
if !is_decoded_event_trade_candidate(event_kind, payload) {
|
||||
return false;
|
||||
}
|
||||
return is_dex_candle_candidate_event_kind(event_kind);
|
||||
}
|
||||
|
||||
/// Enriches a decoded payload with non-destructive classification metadata.
|
||||
pub fn enrich_dex_decoded_payload(
|
||||
protocol_name: &str,
|
||||
event_kind: &str,
|
||||
payload_json: serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
let event_category = classify_dex_event_category_code(event_kind);
|
||||
let trade_candidate = is_dex_trade_event_kind(event_kind);
|
||||
let candle_candidate = is_dex_candle_candidate_event_kind(event_kind);
|
||||
let mut object = match payload_json {
|
||||
serde_json::Value::Object(object) => object,
|
||||
other => {
|
||||
let mut object = serde_json::Map::new();
|
||||
object.insert("rawPayload".to_owned(), other);
|
||||
object
|
||||
},
|
||||
};
|
||||
json_insert_string_if_missing(&mut object, "protocolName", protocol_name);
|
||||
json_insert_string_if_missing(&mut object, "eventKind", event_kind);
|
||||
json_insert_string_if_missing(&mut object, "eventCategory", event_category);
|
||||
json_insert_bool_if_missing(&mut object, "tradeCandidate", trade_candidate);
|
||||
json_insert_bool_if_missing(&mut object, "candleCandidate", candle_candidate);
|
||||
json_insert_i64_if_missing(&mut object, "eventClassificationVersion", 1);
|
||||
if !trade_candidate {
|
||||
json_insert_string_if_missing(&mut object, "skipTradeReason", "non_trade_event");
|
||||
} else if !candle_candidate {
|
||||
json_insert_string_if_missing(
|
||||
&mut object,
|
||||
"skipCandleReason",
|
||||
"route_or_multihop_event_requires_leg_resolution",
|
||||
);
|
||||
}
|
||||
return serde_json::Value::Object(object);
|
||||
}
|
||||
|
||||
/// Enriches a decoded payload and serializes it as JSON.
|
||||
pub fn enrich_and_serialize_dex_decoded_payload(
|
||||
protocol_name: &str,
|
||||
event_kind: &str,
|
||||
payload_json: serde_json::Value,
|
||||
) -> Result<std::string::String, crate::Error> {
|
||||
let enriched_payload = enrich_dex_decoded_payload(protocol_name, event_kind, payload_json);
|
||||
let payload_json_result = serde_json::to_string(&enriched_payload);
|
||||
match payload_json_result {
|
||||
Ok(payload_json) => return Ok(payload_json),
|
||||
Err(error) => {
|
||||
return Err(crate::Error::Json(format!(
|
||||
"cannot serialize enriched decoded payload for '{}': {}",
|
||||
event_kind, error
|
||||
)));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses, enriches and serializes a decoded payload.
|
||||
pub fn enrich_serialized_dex_decoded_payload(
|
||||
protocol_name: &str,
|
||||
event_kind: &str,
|
||||
payload_json: &str,
|
||||
) -> Result<std::string::String, crate::Error> {
|
||||
let payload_value_result = serde_json::from_str::<serde_json::Value>(payload_json);
|
||||
let payload_value = match payload_value_result {
|
||||
Ok(payload_value) => payload_value,
|
||||
Err(error) => {
|
||||
return Err(crate::Error::Json(format!(
|
||||
"cannot parse decoded payload for '{}': {}",
|
||||
event_kind, error
|
||||
)));
|
||||
},
|
||||
};
|
||||
return enrich_and_serialize_dex_decoded_payload(protocol_name, event_kind, payload_value);
|
||||
}
|
||||
|
||||
fn json_insert_string_if_missing(
|
||||
object: &mut serde_json::Map<std::string::String, serde_json::Value>,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) {
|
||||
if object.contains_key(key) {
|
||||
return;
|
||||
}
|
||||
object.insert(key.to_owned(), serde_json::Value::String(value.to_owned()));
|
||||
}
|
||||
|
||||
fn json_insert_bool_if_missing(
|
||||
object: &mut serde_json::Map<std::string::String, serde_json::Value>,
|
||||
key: &str,
|
||||
value: bool,
|
||||
) {
|
||||
if object.contains_key(key) {
|
||||
return;
|
||||
}
|
||||
object.insert(key.to_owned(), serde_json::Value::Bool(value));
|
||||
}
|
||||
|
||||
fn json_insert_i64_if_missing(
|
||||
object: &mut serde_json::Map<std::string::String, serde_json::Value>,
|
||||
key: &str,
|
||||
value: i64,
|
||||
) {
|
||||
if object.contains_key(key) {
|
||||
return;
|
||||
}
|
||||
object.insert(key.to_owned(), serde_json::Value::Number(serde_json::Number::from(value)));
|
||||
}
|
||||
|
||||
fn extract_top_level_bool_by_candidate_keys(
|
||||
payload: &serde_json::Value,
|
||||
candidate_keys: &[&str],
|
||||
) -> std::option::Option<bool> {
|
||||
let object = match payload.as_object() {
|
||||
Some(object) => object,
|
||||
None => return None,
|
||||
};
|
||||
for candidate_key in candidate_keys {
|
||||
let value_option = object.get(*candidate_key);
|
||||
let value = match value_option {
|
||||
Some(value) => value,
|
||||
None => continue,
|
||||
};
|
||||
if let Some(value_bool) = value.as_bool() {
|
||||
return Some(value_bool);
|
||||
}
|
||||
if let Some(value_i64) = value.as_i64() {
|
||||
return Some(value_i64 != 0);
|
||||
}
|
||||
if let Some(value_u64) = value.as_u64() {
|
||||
return Some(value_u64 != 0);
|
||||
}
|
||||
if let Some(value_text) = value.as_str() {
|
||||
let normalized = value_text.trim().to_ascii_lowercase();
|
||||
if normalized.as_str() == "true" {
|
||||
return Some(true);
|
||||
}
|
||||
if normalized.as_str() == "false" {
|
||||
return Some(false);
|
||||
}
|
||||
if normalized.as_str() == "1" {
|
||||
return Some(true);
|
||||
}
|
||||
if normalized.as_str() == "0" {
|
||||
return Some(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
|
||||
fn extract_string_by_candidate_keys(
|
||||
value: &serde_json::Value,
|
||||
candidate_keys: &[&str],
|
||||
) -> std::option::Option<std::string::String> {
|
||||
if let Some(object) = value.as_object() {
|
||||
for candidate_key in candidate_keys {
|
||||
let direct_option = object.get(*candidate_key);
|
||||
if let Some(direct) = direct_option {
|
||||
let direct_text_option = direct.as_str();
|
||||
if let Some(direct_text) = direct_text_option {
|
||||
return Some(direct_text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
for nested_value in object.values() {
|
||||
let nested_result = extract_string_by_candidate_keys(nested_value, candidate_keys);
|
||||
if nested_result.is_some() {
|
||||
return nested_result;
|
||||
}
|
||||
}
|
||||
return None;
|
||||
}
|
||||
if let Some(array) = value.as_array() {
|
||||
for nested_value in array {
|
||||
let nested_result = extract_string_by_candidate_keys(nested_value, candidate_keys);
|
||||
if nested_result.is_some() {
|
||||
return nested_result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return None;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn classifies_swap_events_as_trade_candidates() {
|
||||
assert_eq!(
|
||||
super::classify_dex_event_category_code("raydium_cpmm.swap_base_input"),
|
||||
"trade"
|
||||
);
|
||||
assert_eq!(
|
||||
super::classify_dex_event_category_code("raydium_cpmm.swap_base_output"),
|
||||
"trade"
|
||||
);
|
||||
assert_eq!(super::classify_dex_event_category_code("raydium_clmm.swap"), "trade");
|
||||
assert_eq!(super::classify_dex_event_category_code("raydium_clmm.swap_v2"), "trade");
|
||||
assert_eq!(super::classify_dex_event_category_code("raydium_clmm.exact_output"), "trade");
|
||||
assert_eq!(super::classify_dex_event_category_code("pump_fun.buy"), "trade");
|
||||
assert!(super::is_dex_trade_event_kind("raydium_cpmm.swap_base_input"));
|
||||
assert!(super::is_dex_candle_candidate_event_kind("raydium_cpmm.swap_base_input"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_router_swap_as_trade_but_not_direct_candle_candidate() {
|
||||
assert_eq!(
|
||||
super::classify_dex_event_category_code("raydium_clmm.swap_router_base_in"),
|
||||
"trade"
|
||||
);
|
||||
assert!(super::is_dex_trade_event_kind("raydium_clmm.swap_router_base_in"));
|
||||
assert!(!super::is_dex_candle_candidate_event_kind("raydium_clmm.swap_router_base_in"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_fee_reward_liquidity_and_lifecycle_events() {
|
||||
assert_eq!(
|
||||
super::classify_dex_event_category_code("raydium_cpmm.collect_creator_fee"),
|
||||
"fee"
|
||||
);
|
||||
assert_eq!(
|
||||
super::classify_dex_event_category_code("raydium_clmm.collect_protocol_fee"),
|
||||
"fee"
|
||||
);
|
||||
assert_eq!(
|
||||
super::classify_dex_event_category_code("raydium_clmm.set_reward_params"),
|
||||
"reward"
|
||||
);
|
||||
assert_eq!(
|
||||
super::classify_dex_event_category_code("raydium_clmm.increase_liquidity_v2"),
|
||||
"liquidity"
|
||||
);
|
||||
assert_eq!(
|
||||
super::classify_dex_event_category_code("raydium_cpmm.initialize"),
|
||||
"pool_lifecycle"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enriched_payload_keeps_existing_fields() {
|
||||
let payload_json = serde_json::json!({
|
||||
"eventCategory": "custom",
|
||||
"amountIn": "10"
|
||||
});
|
||||
let enriched_payload = super::enrich_dex_decoded_payload(
|
||||
"raydium_cpmm",
|
||||
"raydium_cpmm.swap_base_input",
|
||||
payload_json,
|
||||
);
|
||||
|
||||
let object_option = enriched_payload.as_object();
|
||||
let object = match object_option {
|
||||
Some(object) => object,
|
||||
None => {
|
||||
panic!("expected enriched payload object");
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
object.get("eventCategory"),
|
||||
Some(&serde_json::Value::String("custom".to_owned()))
|
||||
);
|
||||
assert_eq!(
|
||||
object.get("protocolName"),
|
||||
Some(&serde_json::Value::String("raydium_cpmm".to_owned()))
|
||||
);
|
||||
assert_eq!(
|
||||
object.get("eventKind"),
|
||||
Some(&serde_json::Value::String("raydium_cpmm.swap_base_input".to_owned()))
|
||||
);
|
||||
assert_eq!(object.get("tradeCandidate"), Some(&serde_json::Value::Bool(true)));
|
||||
assert_eq!(object.get("candleCandidate"), Some(&serde_json::Value::Bool(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decoded_event_payload_candidate_flags_win_over_event_kind() {
|
||||
let payload_json = serde_json::json!({
|
||||
"tradeCandidate": false,
|
||||
"candleCandidate": false
|
||||
});
|
||||
assert!(!super::is_decoded_event_trade_candidate("pump_swap.buy", &payload_json));
|
||||
assert!(!super::is_decoded_event_candle_candidate("pump_swap.buy", &payload_json));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user