use serde::Serialize;
use serde_json::{Map, Value};
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TradeRecordKind {
Trade,
Cluster,
Level,
ClusterBomb,
}
const CURRENCY_FIELDS: &[&str] = &[
"Price",
"Dollars",
"ClosePrice",
"Bid",
"Ask",
"AverageBlockSizeDollars",
"AHInstitutionalDollars",
"TotalInstitutionalDollars",
"ClosingTradeDollars",
"TotalDollars",
];
const NON_CURRENCY_FLOAT_FIELDS: &[&str] = &[
"DollarsMultiplier",
"PercentDailyVolume",
"RelativeSize",
"CumulativeDistribution",
"RSIHour",
"RSIDay",
];
const RANK_SENTINEL_FIELDS: &[&str] = &[
"TradeRank",
"TradeClusterRank",
"TradeLevelRank",
"TradeClusterBombRank",
];
const CALENDAR_EVENT_FIELDS: &[&str] = &["EOM", "EOQ", "EOY", "OPEX", "VOLEX"];
pub fn transformed_trade_values<T: Serialize>(
records: &[T],
kind: TradeRecordKind,
) -> serde_json::Result<Vec<Value>> {
let mut values: Vec<Value> = records
.iter()
.map(serde_json::to_value)
.collect::<serde_json::Result<_>>()?;
transform_trade_values(&mut values, kind);
Ok(values)
}
pub fn transform_trade_values(values: &mut [Value], kind: TradeRecordKind) {
for value in values {
let Some(row) = value.as_object_mut() else {
continue;
};
transform_trade_row(row, kind);
}
}
pub fn transform_trade_dashboard(map: &mut Map<String, Value>) {
for (section, kind) in [
("trades", TradeRecordKind::Trade),
("clusters", TradeRecordKind::Cluster),
("levels", TradeRecordKind::Level),
("cluster_bombs", TradeRecordKind::ClusterBomb),
] {
let Some(Value::Array(rows)) = map.get_mut(section) else {
continue;
};
transform_trade_values(rows, kind);
}
}
pub fn transform_trade_row(row: &mut Map<String, Value>, kind: TradeRecordKind) {
match kind {
TradeRecordKind::Trade => {
collapse_trade_type(row);
collapse_venue(row);
omit_redundant_time(row);
}
TradeRecordKind::Cluster | TradeRecordKind::ClusterBomb => {
collapse_time_window(row);
}
TradeRecordKind::Level => {}
}
collapse_calendar_events(row);
omit_sentinel_ranks(row);
round_currency_fields(row);
round_float_fields(row);
compact_date_timezone(row);
}
fn collapse_trade_type(row: &mut Map<String, Value>) {
let opening = row
.remove("OpeningTrade")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let closing = row
.remove("ClosingTrade")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if opening {
row.insert("type".to_string(), Value::String("opening".to_string()));
} else if closing {
row.insert("type".to_string(), Value::String("closing".to_string()));
}
}
fn collapse_venue(row: &mut Map<String, Value>) {
let dark_pool = row
.remove("DarkPool")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let sweep = row
.remove("Sweep")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let venue = match (dark_pool, sweep) {
(false, false) => return,
(false, true) => "lit_sweep",
(true, false) => "dark_pool",
(true, true) => "dark_pool_sweep",
};
row.insert("venue".to_string(), Value::String(venue.to_string()));
}
fn omit_redundant_time(row: &mut Map<String, Value>) {
let trade_type = row.get("type").and_then(Value::as_str);
let time = row.get("FullTimeString24").and_then(Value::as_str);
let redundant = matches!(
(trade_type, time),
(Some("closing"), Some("16:00:00")) | (Some("opening"), Some("09:30:01"))
);
if redundant {
row.remove("FullTimeString24");
}
}
fn collapse_calendar_events(row: &mut Map<String, Value>) {
let mut events = Vec::new();
for &field in CALENDAR_EVENT_FIELDS {
let is_true = row.remove(field).and_then(|v| v.as_bool()).unwrap_or(false);
if is_true {
events.push(Value::String(field.to_string()));
}
}
if !events.is_empty() {
row.insert("events".to_string(), Value::Array(events));
}
}
fn omit_sentinel_ranks(row: &mut Map<String, Value>) {
for &field in RANK_SENTINEL_FIELDS {
let is_sentinel = row
.get(field)
.and_then(Value::as_i64)
.is_some_and(|n| n == 9999 || n == 0);
if is_sentinel {
row.remove(field);
}
}
}
fn round_currency_fields(row: &mut Map<String, Value>) {
for &field in CURRENCY_FIELDS {
let rounded = row
.get(field)
.and_then(Value::as_f64)
.map(|f| (f * 100.0).round() / 100.0);
if let Some(n) = rounded.and_then(serde_json::Number::from_f64) {
row.insert(field.to_string(), Value::Number(n));
}
}
}
fn round_float_fields(row: &mut Map<String, Value>) {
for &field in NON_CURRENCY_FLOAT_FIELDS {
let rounded = row
.get(field)
.and_then(Value::as_f64)
.map(|f| (f * 100.0).round() / 100.0);
if let Some(n) = rounded.and_then(serde_json::Number::from_f64) {
row.insert(field.to_string(), Value::Number(n));
}
}
}
fn compact_date_timezone(row: &mut Map<String, Value>) {
for value in row.values_mut() {
let Some(s) = value.as_str() else { continue };
if let Some(prefix) = s.strip_suffix("+00:00") {
if let Some(date) = prefix.strip_suffix("T00:00:00") {
*value = Value::String(date.to_string());
} else {
*value = Value::String(format!("{prefix}Z"));
}
} else if let Some(date) = s.strip_suffix("T00:00:00Z") {
*value = Value::String(date.to_string());
}
}
}
fn collapse_time_window(row: &mut Map<String, Value>) {
let extract_time = |v: &Value| -> Option<String> {
let s = v.as_str()?;
let after_t = s.split('T').nth(1)?;
let time = after_t
.strip_suffix("+00:00")
.or_else(|| after_t.strip_suffix('Z'))
.unwrap_or(after_t);
Some(time.to_string())
};
let min_time = row.get("MinFullDateTime").and_then(&extract_time);
let max_time = row.get("MaxFullDateTime").and_then(&extract_time);
if let (Some(min), Some(max)) = (min_time, max_time) {
row.remove("MinFullDateTime");
row.remove("MaxFullDateTime");
row.insert("window".to_string(), Value::String(format!("{min}-{max}")));
}
}
#[cfg(test)]
mod tests {
use serde::Serialize;
use serde_json::json;
use super::*;
#[test]
fn trade_transform_collapses_trade_semantics() {
let mut value = json!({
"FullTimeString24": "16:00:00",
"Dollars": 10.126,
"TradeRank": 9999,
"DarkPool": true,
"Sweep": true,
"ClosingTrade": true,
"OPEX": true,
"EOM": false
});
let row = value.as_object_mut().unwrap();
transform_trade_row(row, TradeRecordKind::Trade);
assert_eq!(row["Dollars"], 10.13);
assert_eq!(row["type"], "closing");
assert_eq!(row["venue"], "dark_pool_sweep");
assert_eq!(row["events"], json!(["OPEX"]));
assert!(!row.contains_key("FullTimeString24"));
assert!(!row.contains_key("TradeRank"));
assert!(!row.contains_key("DarkPool"));
assert!(!row.contains_key("Sweep"));
assert!(!row.contains_key("ClosingTrade"));
}
#[test]
fn cluster_transform_collapses_time_window() {
let mut value = json!({
"MinFullDateTime": "2026-01-02T16:00:00+00:00",
"MaxFullDateTime": "2026-01-02T16:49:31+00:00",
"TradeClusterRank": 2
});
let row = value.as_object_mut().unwrap();
transform_trade_row(row, TradeRecordKind::Cluster);
assert_eq!(row["window"], "16:00:00-16:49:31");
assert!(!row.contains_key("MinFullDateTime"));
assert!(!row.contains_key("MaxFullDateTime"));
}
#[test]
fn transformed_trade_values_surfaces_serialization_errors() {
#[derive(Debug)]
struct FailingRecord;
impl Serialize for FailingRecord {
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
Err(serde::ser::Error::custom("serialize failed"))
}
}
let err = transformed_trade_values(&[FailingRecord], TradeRecordKind::Trade).unwrap_err();
assert!(err.to_string().contains("serialize failed"));
}
}