use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct EquityPoint {
pub step: u32,
pub ts_ns: i64,
pub cash_cents: i64,
pub position_value_cents: i64,
pub equity_cents: i64,
pub drawdown: f64,
}
impl EquityPoint {
#[must_use]
pub const fn new(
step: u32,
ts_ns: i64,
cash_cents: i64,
position_value_cents: i64,
equity_cents: i64,
drawdown: f64,
) -> Self {
Self {
step,
ts_ns,
cash_cents,
position_value_cents,
equity_cents,
drawdown,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct GreeksAttributionRow {
pub step: u32,
pub ts_ns: i64,
pub theta_pnl_cents: i64,
pub delta_pnl_cents: i64,
pub vega_pnl_cents: i64,
pub spread_capture_cents: i64,
pub fees_cents: i64,
pub residual_cents: i64,
}
impl GreeksAttributionRow {
#[must_use]
#[allow(
clippy::too_many_arguments,
reason = "one argument per wire column of the fixed GreeksAttributionRow schema (docs/05 §10); the constructor centralises the field order in one place"
)]
pub const fn new(
step: u32,
ts_ns: i64,
theta_pnl_cents: i64,
delta_pnl_cents: i64,
vega_pnl_cents: i64,
spread_capture_cents: i64,
fees_cents: i64,
residual_cents: i64,
) -> Self {
Self {
step,
ts_ns,
theta_pnl_cents,
delta_pnl_cents,
vega_pnl_cents,
spread_capture_cents,
fees_cents,
residual_cents,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FillRow {
pub step: u32,
pub ts_ns: i64,
pub strategy_run_id: String,
pub trade_id: u64,
pub position_id: u64,
pub order_id: u64,
pub fill_seq: u32,
pub underlying: String,
pub expiration_ns: i64,
pub contract_id: String,
pub strike_cents: u64,
pub style: String,
pub side: String,
pub quantity: u32,
pub price_cents: u64,
pub fees_cents: i64,
pub slippage_cents: i64,
pub mode: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PositionRow {
pub step: u32,
pub ts_ns: i64,
pub position_id: u64,
pub trade_id: u64,
pub contract_id: String,
pub side: String,
pub quantity: u32,
pub avg_price_cents: u64,
pub mark_cents: u64,
pub unrealized_cents: i64,
pub stale_mark: bool,
pub exit_reason: Option<String>,
pub open_at_end: bool,
}
#[cfg(test)]
mod tests {
use super::{EquityPoint, FillRow, GreeksAttributionRow, PositionRow};
#[test]
fn test_greeks_attribution_row_serialises_money_as_bare_integers() {
let row = GreeksAttributionRow::new(0, 1_000, 0, 0, 0, -60, 520, 2_060);
let Ok(json) = serde_json::to_value(&row) else {
panic!("attribution row serialises");
};
for (field, expected) in [
("theta_pnl_cents", 0_i64),
("delta_pnl_cents", 0),
("vega_pnl_cents", 0),
("spread_capture_cents", -60),
("fees_cents", 520),
("residual_cents", 2_060),
] {
assert!(
matches!(json.get(field).and_then(serde_json::Value::as_i64), Some(v) if v == expected),
"{field} must serialise as the bare integer {expected}"
);
}
}
#[test]
fn test_greeks_attribution_row_round_trips_through_json() {
let row = GreeksAttributionRow::new(3, 42, -120, 340, -55, 12, 130, 7);
let Ok(json) = serde_json::to_string(&row) else {
panic!("serialises");
};
let back: Result<GreeksAttributionRow, _> = serde_json::from_str(&json);
assert!(matches!(back, Ok(r) if r == row));
}
#[test]
fn test_equity_point_serialises_money_as_bare_integers() {
let point = EquityPoint::new(3, 1_000, 9_998_000, -1_500, 9_996_500, -0.000_15);
let Ok(json) = serde_json::to_value(&point) else {
panic!("equity point serialises");
};
assert!(matches!(
json.get("cash_cents").and_then(|v| v.as_i64()),
Some(9_998_000)
));
assert!(matches!(
json.get("position_value_cents").and_then(|v| v.as_i64()),
Some(-1_500)
));
assert!(matches!(
json.get("equity_cents").and_then(|v| v.as_i64()),
Some(9_996_500)
));
assert!(json.get("drawdown").and_then(|v| v.as_f64()).is_some());
}
#[test]
fn test_equity_point_round_trips_through_json() {
let point = EquityPoint::new(0, 42, 10_000_000, 0, 10_000_000, 0.0);
let Ok(json) = serde_json::to_string(&point) else {
panic!("serialises");
};
let back: Result<EquityPoint, _> = serde_json::from_str(&json);
assert!(matches!(back, Ok(p) if p == point));
}
fn sample_fill_row() -> FillRow {
FillRow {
step: 2,
ts_ns: 1_750_291_200_000_000_000,
strategy_run_id: "deadbeef".to_string(),
trade_id: 7,
position_id: 11,
order_id: 42,
fill_seq: 1,
underlying: "SPX".to_string(),
expiration_ns: 1_750_291_200_000_000_000,
contract_id: "v1:SPX:1750291200000000000:510000:C".to_string(),
strike_cents: 510_000,
style: "call".to_string(),
side: "short".to_string(),
quantity: 3,
price_cents: 1_970,
fees_cents: 65,
slippage_cents: -2,
mode: "realistic".to_string(),
}
}
fn sample_position_row(exit_reason: Option<String>) -> PositionRow {
PositionRow {
step: 4,
ts_ns: 1_750_291_200_000_000_000,
position_id: 11,
trade_id: 7,
contract_id: "v1:SPX:1750291200000000000:510000:C".to_string(),
side: "short".to_string(),
quantity: 3,
avg_price_cents: 2_000,
mark_cents: 1_950,
unrealized_cents: 150,
stale_mark: false,
exit_reason,
open_at_end: false,
}
}
#[test]
fn test_fill_row_has_the_docs_09_field_set() {
let Ok(json) = serde_json::to_value(sample_fill_row()) else {
panic!("fill row serialises");
};
let Some(obj) = json.as_object() else {
panic!("fill row is a JSON object");
};
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
keys.sort_unstable();
let mut expected = [
"step",
"ts_ns",
"strategy_run_id",
"trade_id",
"position_id",
"order_id",
"fill_seq",
"underlying",
"expiration_ns",
"contract_id",
"strike_cents",
"style",
"side",
"quantity",
"price_cents",
"fees_cents",
"slippage_cents",
"mode",
];
expected.sort_unstable();
assert_eq!(
keys, expected,
"fill row must carry exactly the docs/01 §9 columns"
);
assert!(matches!(
obj.get("price_cents").and_then(serde_json::Value::as_u64),
Some(1_970)
));
assert!(matches!(
obj.get("fees_cents").and_then(serde_json::Value::as_i64),
Some(65)
));
assert!(matches!(
obj.get("slippage_cents")
.and_then(serde_json::Value::as_i64),
Some(-2)
));
}
#[test]
fn test_fill_row_round_trips_through_json() {
let row = sample_fill_row();
let Ok(json) = serde_json::to_string(&row) else {
panic!("serialises");
};
let back: Result<FillRow, _> = serde_json::from_str(&json);
assert!(matches!(back, Ok(r) if r == row));
}
#[test]
fn test_position_row_has_the_docs_09_field_set() {
let Ok(json) = serde_json::to_value(sample_position_row(None)) else {
panic!("position row serialises");
};
let Some(obj) = json.as_object() else {
panic!("position row is a JSON object");
};
let mut keys: Vec<&str> = obj.keys().map(String::as_str).collect();
keys.sort_unstable();
let mut expected = [
"step",
"ts_ns",
"position_id",
"trade_id",
"contract_id",
"side",
"quantity",
"avg_price_cents",
"mark_cents",
"unrealized_cents",
"stale_mark",
"exit_reason",
"open_at_end",
];
expected.sort_unstable();
assert_eq!(
keys, expected,
"position row must carry exactly the docs/01 §9 columns"
);
}
#[test]
fn test_position_row_exit_reason_is_the_only_nullable_column() {
let Ok(json) = serde_json::to_value(sample_position_row(None)) else {
panic!("open position row serialises");
};
let Some(obj) = json.as_object() else {
panic!("object");
};
assert!(
matches!(obj.get("exit_reason"), Some(serde_json::Value::Null)),
"an open leg's exit_reason is null"
);
for (key, value) in obj {
if key == "exit_reason" {
continue;
}
assert!(
!value.is_null(),
"no column other than exit_reason may be null ({key})"
);
}
let Ok(json) = serde_json::to_value(sample_position_row(Some("expiration".to_string())))
else {
panic!("closed position row serialises");
};
assert!(matches!(
json.get("exit_reason").and_then(serde_json::Value::as_str),
Some("expiration")
));
}
#[test]
fn test_position_row_round_trips_through_json_open_and_closed() {
for reason in [None, Some("stop_loss".to_string())] {
let row = sample_position_row(reason);
let Ok(json) = serde_json::to_string(&row) else {
panic!("serialises");
};
let back: Result<PositionRow, _> = serde_json::from_str(&json);
assert!(matches!(back, Ok(r) if r == row));
}
}
}