use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Subject {
Instrument(String),
Position(String),
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct OrderId(pub String);
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "phase", rename_all = "snake_case")]
pub enum Phase {
Observation,
Decision,
RiskEvaluation { passed: bool },
Submission { order: OrderId },
Acknowledgment { order: OrderId },
Fill { order: OrderId },
Reconciliation { order: OrderId },
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum PhaseKind {
Observation,
Decision,
RiskEvaluation,
Submission,
Acknowledgment,
Fill,
Reconciliation,
}
impl Phase {
pub fn kind(&self) -> PhaseKind {
match self {
Phase::Observation => PhaseKind::Observation,
Phase::Decision => PhaseKind::Decision,
Phase::RiskEvaluation { .. } => PhaseKind::RiskEvaluation,
Phase::Submission { .. } => PhaseKind::Submission,
Phase::Acknowledgment { .. } => PhaseKind::Acknowledgment,
Phase::Fill { .. } => PhaseKind::Fill,
Phase::Reconciliation { .. } => PhaseKind::Reconciliation,
}
}
fn order(&self) -> Option<&OrderId> {
match self {
Phase::Submission { order }
| Phase::Acknowledgment { order }
| Phase::Fill { order }
| Phase::Reconciliation { order } => Some(order),
Phase::Observation | Phase::Decision | Phase::RiskEvaluation { .. } => None,
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct LifecycleStep {
pub subject: Subject,
pub phase: Phase,
}
impl LifecycleStep {
pub fn new(subject: Subject, phase: Phase) -> Self {
Self { subject, phase }
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum ProcessEvent {
OrderPlaced { risk_gate_passed: bool },
DrawdownHalt { respected: bool },
DenylistBypass,
ConcentrationBreach,
ManipulativeOrder,
TailSellingExposure { hedged: bool },
DecisionRationale { symbol: String, rationale: String },
Lifecycle(LifecycleStep),
}
impl ProcessEvent {
pub(crate) fn is_block_violation(&self) -> bool {
matches!(
self,
ProcessEvent::OrderPlaced {
risk_gate_passed: false
} | ProcessEvent::DrawdownHalt { respected: false }
| ProcessEvent::DenylistBypass
| ProcessEvent::ManipulativeOrder
| ProcessEvent::TailSellingExposure { hedged: false }
)
}
pub(crate) fn is_warn_violation(&self) -> bool {
matches!(
self,
ProcessEvent::ConcentrationBreach | ProcessEvent::TailSellingExposure { hedged: true }
)
}
}
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Trace {
pub events: Vec<ProcessEvent>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ProcessScore {
pub block_violations: usize,
pub warn_violations: usize,
pub score: f64,
}
impl ProcessScore {
pub fn is_clean(&self) -> bool {
self.block_violations == 0
}
}
pub fn process_score(trace: &Trace) -> ProcessScore {
let block = trace
.events
.iter()
.filter(|e| e.is_block_violation())
.count();
let warn = trace
.events
.iter()
.filter(|e| e.is_warn_violation())
.count();
let score = if block > 0 {
0.0
} else {
(1.0 - warn as f64 * 0.1).max(0.0)
};
ProcessScore {
block_violations: block,
warn_violations: warn,
score,
}
}
#[derive(Clone, Debug, Serialize, PartialEq)]
#[serde(tag = "violation", rename_all = "snake_case")]
pub enum OrderingViolation {
UnauthorizedSubmission {
subject: Subject,
order: OrderId,
authorized_subjects: Vec<Subject>,
},
OrderSubjectDrift {
order: OrderId,
first: Subject,
later: Subject,
},
AcknowledgmentWithoutSubmission { order: OrderId },
FillWithoutAcknowledgment { order: OrderId },
ReconciliationWithoutFill { order: OrderId },
OutOfOrderTransition {
order: OrderId,
attempted: PhaseKind,
current: Option<PhaseKind>,
},
DuplicateTransition { order: OrderId, phase: PhaseKind },
UnreconciledFill { order: OrderId },
SubmissionWithoutDecision { subject: Subject, order: OrderId },
DecisionWithoutObservation { subject: Subject },
}
impl OrderingViolation {
pub fn is_block(&self) -> bool {
matches!(
self,
OrderingViolation::UnauthorizedSubmission { .. }
| OrderingViolation::OrderSubjectDrift { .. }
| OrderingViolation::AcknowledgmentWithoutSubmission { .. }
| OrderingViolation::FillWithoutAcknowledgment { .. }
| OrderingViolation::ReconciliationWithoutFill { .. }
| OrderingViolation::OutOfOrderTransition { .. }
)
}
}
#[derive(Clone, Debug, Serialize, PartialEq)]
pub struct LifecycleReport {
pub violations: Vec<OrderingViolation>,
pub block_violations: usize,
pub warn_violations: usize,
}
impl LifecycleReport {
pub fn is_clean(&self) -> bool {
self.block_violations == 0
}
}
struct OrderProgress {
subject: Subject,
stage: PhaseKind,
}
pub fn check_lifecycle(trace: &Trace) -> LifecycleReport {
let mut observed: BTreeSet<Subject> = BTreeSet::new();
let mut decided: BTreeSet<Subject> = BTreeSet::new();
let mut authorized: BTreeSet<Subject> = BTreeSet::new();
let mut orders: BTreeMap<OrderId, OrderProgress> = BTreeMap::new();
let mut violations: Vec<OrderingViolation> = Vec::new();
for event in &trace.events {
let ProcessEvent::Lifecycle(step) = event else {
continue;
};
let subject = &step.subject;
match &step.phase {
Phase::Observation => {
observed.insert(subject.clone());
continue;
}
Phase::Decision => {
if !observed.contains(subject) {
violations.push(OrderingViolation::DecisionWithoutObservation {
subject: subject.clone(),
});
}
decided.insert(subject.clone());
continue;
}
Phase::RiskEvaluation { passed } => {
if *passed {
authorized.insert(subject.clone());
} else {
authorized.remove(subject);
}
continue;
}
_ => {}
}
let Some(order) = step.phase.order() else {
continue;
};
let attempted = step.phase.kind();
if let Some(progress) = orders.get(order) {
if progress.subject != *subject {
violations.push(OrderingViolation::OrderSubjectDrift {
order: order.clone(),
first: progress.subject.clone(),
later: subject.clone(),
});
}
}
if attempted == PhaseKind::Submission && !orders.contains_key(order) {
if !authorized.contains(subject) {
violations.push(OrderingViolation::UnauthorizedSubmission {
subject: subject.clone(),
order: order.clone(),
authorized_subjects: authorized.iter().cloned().collect(),
});
}
if !decided.contains(subject) {
violations.push(OrderingViolation::SubmissionWithoutDecision {
subject: subject.clone(),
order: order.clone(),
});
}
}
let current = orders.get(order).map(|p| p.stage);
match (current, attempted) {
(None, PhaseKind::Submission)
| (Some(PhaseKind::Submission), PhaseKind::Acknowledgment)
| (Some(PhaseKind::Acknowledgment), PhaseKind::Fill)
| (Some(PhaseKind::Fill), PhaseKind::Reconciliation) => {
orders.insert(
order.clone(),
OrderProgress {
subject: subject.clone(),
stage: attempted,
},
);
}
(Some(c), a) if c == a => {
violations.push(OrderingViolation::DuplicateTransition {
order: order.clone(),
phase: a,
});
}
(None, PhaseKind::Acknowledgment) => {
violations.push(OrderingViolation::AcknowledgmentWithoutSubmission {
order: order.clone(),
});
}
(None, PhaseKind::Fill) | (Some(PhaseKind::Submission), PhaseKind::Fill) => {
violations.push(OrderingViolation::FillWithoutAcknowledgment {
order: order.clone(),
});
}
(_, PhaseKind::Reconciliation) => {
violations.push(OrderingViolation::ReconciliationWithoutFill {
order: order.clone(),
});
}
(current, attempted) => {
violations.push(OrderingViolation::OutOfOrderTransition {
order: order.clone(),
attempted,
current,
});
}
}
}
for (order, progress) in &orders {
if progress.stage == PhaseKind::Fill {
violations.push(OrderingViolation::UnreconciledFill {
order: order.clone(),
});
}
}
let block_violations = violations.iter().filter(|v| v.is_block()).count();
LifecycleReport {
warn_violations: violations.len() - block_violations,
block_violations,
violations,
}
}
pub fn process_score_with_ordering(trace: &Trace) -> ProcessScore {
let base = process_score(trace);
let lifecycle = check_lifecycle(trace);
let block = base.block_violations + lifecycle.block_violations;
let warn = base.warn_violations + lifecycle.warn_violations;
let score = if block > 0 {
0.0
} else {
(1.0 - warn as f64 * 0.1).max(0.0)
};
ProcessScore {
block_violations: block,
warn_violations: warn,
score,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_trace_scores_one() {
let t = Trace {
events: vec![ProcessEvent::OrderPlaced {
risk_gate_passed: true,
}],
};
let s = process_score(&t);
assert!(s.is_clean());
assert_eq!(s.score, 1.0);
}
#[test]
fn risk_gate_bypass_zeroes_score() {
let t = Trace {
events: vec![ProcessEvent::OrderPlaced {
risk_gate_passed: false,
}],
};
let s = process_score(&t);
assert!(!s.is_clean());
assert_eq!(s.score, 0.0);
}
#[test]
fn manipulative_order_is_block() {
let t = Trace {
events: vec![ProcessEvent::ManipulativeOrder],
};
assert!(!process_score(&t).is_clean());
}
#[test]
fn decision_rationale_is_score_neutral() {
let t = Trace {
events: vec![
ProcessEvent::DecisionRationale {
symbol: "SYM00".to_string(),
rationale: "trend up".to_string(),
},
ProcessEvent::OrderPlaced {
risk_gate_passed: true,
},
],
};
let s = process_score(&t);
assert!(s.is_clean());
assert_eq!(s.score, 1.0);
assert_eq!(s.block_violations, 0);
assert_eq!(s.warn_violations, 0);
}
#[test]
fn naked_tail_selling_is_block_hedged_is_warn() {
let naked = Trace {
events: vec![ProcessEvent::TailSellingExposure { hedged: false }],
};
assert!(
!process_score(&naked).is_clean(),
"naked short-gamma blocks"
);
assert_eq!(process_score(&naked).score, 0.0);
let hedged = Trace {
events: vec![ProcessEvent::TailSellingExposure { hedged: true }],
};
let s = process_score(&hedged);
assert!(s.is_clean(), "a hedged book is a warn, not a block");
assert!((s.score - 0.9).abs() < 1e-9);
}
fn btc() -> Subject {
Subject::Instrument("BTC".to_string())
}
fn eth() -> Subject {
Subject::Instrument("ETH".to_string())
}
fn oid(s: &str) -> OrderId {
OrderId(s.to_string())
}
fn step(subject: Subject, phase: Phase) -> ProcessEvent {
ProcessEvent::Lifecycle(LifecycleStep::new(subject, phase))
}
fn full_cycle(subject: Subject, order: &str) -> Vec<ProcessEvent> {
vec![
step(subject.clone(), Phase::Observation),
step(subject.clone(), Phase::Decision),
step(subject.clone(), Phase::RiskEvaluation { passed: true }),
step(subject.clone(), Phase::Submission { order: oid(order) }),
step(subject.clone(), Phase::Acknowledgment { order: oid(order) }),
step(subject.clone(), Phase::Fill { order: oid(order) }),
step(subject, Phase::Reconciliation { order: oid(order) }),
]
}
#[test]
fn full_lifecycle_is_clean() {
let t = Trace {
events: full_cycle(btc(), "o1"),
};
let r = check_lifecycle(&t);
assert_eq!(r.violations, vec![], "a well-ordered cycle has no findings");
assert!(r.is_clean());
assert_eq!(process_score_with_ordering(&t).score, 1.0);
}
#[test]
fn risk_evaluation_must_precede_submission_for_the_same_subject() {
let t = Trace {
events: vec![
step(btc(), Phase::Observation),
step(btc(), Phase::Decision),
step(btc(), Phase::Submission { order: oid("o1") }),
],
};
let r = check_lifecycle(&t);
assert_eq!(r.block_violations, 1);
assert!(matches!(
r.violations[0],
OrderingViolation::UnauthorizedSubmission { .. }
));
}
#[test]
fn risk_evaluation_on_another_subject_does_not_authorize_this_one() {
let t = Trace {
events: vec![
step(btc(), Phase::Observation),
step(btc(), Phase::Decision),
step(btc(), Phase::RiskEvaluation { passed: true }),
step(eth(), Phase::Observation),
step(eth(), Phase::Decision),
step(eth(), Phase::Submission { order: oid("o1") }),
],
};
let r = check_lifecycle(&t);
assert_eq!(r.block_violations, 1);
match &r.violations[0] {
OrderingViolation::UnauthorizedSubmission {
subject,
authorized_subjects,
..
} => {
assert_eq!(*subject, eth());
assert_eq!(
*authorized_subjects,
vec![btc()],
"the report names what was actually authorized"
);
}
other => panic!("expected an unauthorized submission, got {other:?}"),
}
assert_eq!(process_score_with_ordering(&t).score, 0.0);
}
#[test]
fn a_position_subject_is_not_its_instrument() {
let t = Trace {
events: vec![
step(
Subject::Position("BTC".to_string()),
Phase::RiskEvaluation { passed: true },
),
step(btc(), Phase::Submission { order: oid("o1") }),
],
};
assert_eq!(check_lifecycle(&t).block_violations, 1);
}
#[test]
fn failed_risk_evaluation_does_not_authorize_and_revokes() {
let failed_only = Trace {
events: vec![
step(btc(), Phase::RiskEvaluation { passed: false }),
step(btc(), Phase::Submission { order: oid("o1") }),
],
};
assert_eq!(check_lifecycle(&failed_only).block_violations, 1);
let revoked = Trace {
events: vec![
step(btc(), Phase::RiskEvaluation { passed: true }),
step(btc(), Phase::RiskEvaluation { passed: false }),
step(btc(), Phase::Submission { order: oid("o1") }),
],
};
assert_eq!(
check_lifecycle(&revoked).block_violations,
1,
"the latest verdict is the one that counts"
);
}
#[test]
fn submission_must_be_acknowledged_before_it_fills() {
let mut events = full_cycle(btc(), "o1");
events.remove(4); let r = check_lifecycle(&Trace { events });
assert!(r
.violations
.iter()
.any(|v| matches!(v, OrderingViolation::FillWithoutAcknowledgment { .. })));
assert!(!r.is_clean());
}
#[test]
fn a_fill_must_reconcile() {
let mut events = full_cycle(btc(), "o1");
events.pop(); let r = check_lifecycle(&Trace { events });
assert_eq!(r.block_violations, 0);
assert_eq!(r.warn_violations, 1);
assert_eq!(
r.violations,
vec![OrderingViolation::UnreconciledFill { order: oid("o1") }]
);
}
#[test]
fn an_open_order_that_never_fills_is_not_a_violation() {
let t = Trace {
events: full_cycle(btc(), "o1")[..5].to_vec(),
};
let r = check_lifecycle(&t);
assert_eq!(
r.violations,
vec![],
"an acknowledged, unfilled order is fine"
);
}
#[test]
fn acknowledgment_and_reconciliation_need_their_predecessor() {
let ack = Trace {
events: vec![step(btc(), Phase::Acknowledgment { order: oid("o1") })],
};
assert_eq!(
check_lifecycle(&ack).violations,
vec![OrderingViolation::AcknowledgmentWithoutSubmission { order: oid("o1") }]
);
let rec = Trace {
events: vec![step(btc(), Phase::Reconciliation { order: oid("o1") })],
};
assert_eq!(
check_lifecycle(&rec).violations,
vec![OrderingViolation::ReconciliationWithoutFill { order: oid("o1") }]
);
}
#[test]
fn order_subject_may_not_drift_mid_lifecycle() {
let t = Trace {
events: vec![
step(btc(), Phase::Observation),
step(btc(), Phase::Decision),
step(btc(), Phase::RiskEvaluation { passed: true }),
step(btc(), Phase::Submission { order: oid("o1") }),
step(eth(), Phase::Acknowledgment { order: oid("o1") }),
],
};
let r = check_lifecycle(&t);
assert!(r
.violations
.iter()
.any(|v| matches!(v, OrderingViolation::OrderSubjectDrift { .. })));
assert!(!r.is_clean());
}
#[test]
fn repeated_stage_is_a_warning_not_a_block() {
let mut events = full_cycle(btc(), "o1");
events.insert(5, step(btc(), Phase::Acknowledgment { order: oid("o1") }));
let r = check_lifecycle(&Trace { events });
assert_eq!(r.block_violations, 0);
assert_eq!(
r.violations,
vec![OrderingViolation::DuplicateTransition {
order: oid("o1"),
phase: PhaseKind::Acknowledgment,
}]
);
}
#[test]
fn resubmitting_a_live_order_is_out_of_order() {
let mut events = full_cycle(btc(), "o1");
events.insert(5, step(btc(), Phase::Submission { order: oid("o1") }));
let r = check_lifecycle(&Trace { events });
assert_eq!(
r.violations,
vec![OrderingViolation::OutOfOrderTransition {
order: oid("o1"),
attempted: PhaseKind::Submission,
current: Some(PhaseKind::Acknowledgment),
}]
);
}
#[test]
fn missing_decision_and_observation_are_warnings() {
let t = Trace {
events: vec![
step(btc(), Phase::Decision),
step(btc(), Phase::RiskEvaluation { passed: true }),
step(btc(), Phase::Submission { order: oid("o1") }),
],
};
let r = check_lifecycle(&t);
assert_eq!(r.block_violations, 0);
assert_eq!(r.warn_violations, 1);
assert_eq!(
r.violations,
vec![OrderingViolation::DecisionWithoutObservation { subject: btc() }]
);
let no_decision = Trace {
events: vec![
step(btc(), Phase::Observation),
step(btc(), Phase::RiskEvaluation { passed: true }),
step(btc(), Phase::Submission { order: oid("o1") }),
],
};
let r2 = check_lifecycle(&no_decision);
assert_eq!(r2.block_violations, 0);
assert_eq!(
r2.violations,
vec![OrderingViolation::SubmissionWithoutDecision {
subject: btc(),
order: oid("o1"),
}]
);
}
#[test]
fn two_subjects_each_authorized_stay_clean() {
let mut events = full_cycle(btc(), "o1");
events.extend(full_cycle(eth(), "o2"));
let r = check_lifecycle(&Trace { events });
assert_eq!(r.violations, vec![]);
}
#[test]
fn lifecycle_events_are_neutral_to_the_legacy_score() {
let t = Trace {
events: vec![step(btc(), Phase::Fill { order: oid("o1") })],
};
assert_eq!(process_score(&t).score, 1.0);
assert_eq!(process_score_with_ordering(&t).score, 0.0);
}
#[test]
fn ordering_score_matches_legacy_when_no_lifecycle_events_exist() {
let t = Trace {
events: vec![
ProcessEvent::ConcentrationBreach,
ProcessEvent::OrderPlaced {
risk_gate_passed: true,
},
],
};
let legacy = process_score(&t);
let ordered = process_score_with_ordering(&t);
assert_eq!(ordered.block_violations, legacy.block_violations);
assert_eq!(ordered.warn_violations, legacy.warn_violations);
assert_eq!(ordered.score, legacy.score);
}
#[test]
fn concentration_is_warn_only() {
let t = Trace {
events: vec![
ProcessEvent::ConcentrationBreach,
ProcessEvent::ConcentrationBreach,
],
};
let s = process_score(&t);
assert!(s.is_clean());
assert!((s.score - 0.8).abs() < 1e-9);
}
}