建立手工成交观察合同与原子回放游标
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
fn sample() -> ManualExecutionReplay {
|
||||
let mut input:ManualExecutionReplay=serde_json::from_value(json!({
|
||||
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"runtime-1","accountId":"account-1",
|
||||
"sourceContractSha256":"a".repeat(64),"contentSha256":"", "observationCutoff":"2026-09-14T08:00:00Z",
|
||||
"actions":[{"actionId":"action-1","source":"manual_security_trade","auditEventIds":["audit-1"],
|
||||
"confirmedAt":"2026-09-14T01:30:00.500Z","outcome":"orders_terminal","orders":[{
|
||||
"orderId":"order-1","brokerOrderId":"broker-1","sourceAdapter":"gt-api","symbol":"000001.SZ","side":"Buy","quantity":100,
|
||||
"submittedAt":"2026-09-14T01:30:00.600Z","terminalAt":"2026-09-14T01:30:00.900Z","terminalStatus":"filled",
|
||||
"fills":[{"tradeId":"trade-1","observationEventId":"received-1","observationSequence":1,"tradeDate":"2026-09-14","executedAt":"2026-09-14T01:30:00Z",
|
||||
"observedAt":"2026-09-14T01:30:01Z","timestampPrecision":"second","quantity":100,
|
||||
"price":"10.1234567891","commission":"0.1000001","stampTax":"0","transferFee":"0.02"}]
|
||||
}]}]
|
||||
})).unwrap();
|
||||
reseal(&mut input);
|
||||
input
|
||||
}
|
||||
|
||||
fn reseal(input: &mut ManualExecutionReplay) {
|
||||
input.content_sha256 = input.content_digest().unwrap();
|
||||
}
|
||||
fn semantic_result(input: &ManualExecutionReplay) -> Result<(), String> {
|
||||
let mut input = input.clone();
|
||||
reseal(&mut input);
|
||||
input.validate()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_exact_decimal_evidence_allows_later_observation_and_retains_source_digits() {
|
||||
let input = sample();
|
||||
input.validate().unwrap();
|
||||
let fill = &input.actions[0].orders[0].fills[0];
|
||||
assert_eq!(fill.gross_amount().unwrap().to_string(), "1012.3456789100");
|
||||
assert_eq!(fill.total_fees().unwrap().to_string(), "0.1200001");
|
||||
assert_eq!(
|
||||
serde_json::to_value(&input).unwrap()["actions"][0]["orders"][0]["fills"][0]["price"],
|
||||
"10.1234567891"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() {
|
||||
let original = serde_json::to_value(sample()).unwrap();
|
||||
for field in ["price", "commission", "stampTax", "transferFee"] {
|
||||
let mut missing = original.clone();
|
||||
missing["actions"][0]["orders"][0]["fills"][0]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove(field);
|
||||
assert!(
|
||||
serde_json::from_value::<ManualExecutionReplay>(missing).is_err(),
|
||||
"{field}"
|
||||
);
|
||||
let mut numeric = original.clone();
|
||||
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(1.1);
|
||||
assert!(
|
||||
serde_json::from_value::<ManualExecutionReplay>(numeric).is_err(),
|
||||
"numeric {field}"
|
||||
);
|
||||
}
|
||||
for mutate in [
|
||||
("schema", json!("unknown")),
|
||||
("sourceContractSha256", json!("broken")),
|
||||
("accountId", json!(" ")),
|
||||
] {
|
||||
let mut value = original.clone();
|
||||
value[mutate.0] = mutate.1;
|
||||
assert!(
|
||||
semantic_result(&serde_json::from_value::<ManualExecutionReplay>(value).unwrap())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inconsistent_counts_terminals_audits_and_duplicate_facts_are_rejected() {
|
||||
let original = sample();
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].orders[0].quantity = 200;
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Rejected;
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].audit_event_ids.clear();
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions.push(invalid.actions[0].clone());
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
let duplicate = invalid.actions[0].orders[0].fills[0].clone();
|
||||
invalid.actions[0].orders[0].fills.push(duplicate);
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
let mut invalid = original.clone();
|
||||
invalid.actions[0].orders[0].broker_order_id = None;
|
||||
assert!(semantic_result(&invalid).is_err());
|
||||
invalid.actions[0].orders[0].source_adapter = "paper".into();
|
||||
reseal(&mut invalid);
|
||||
invalid.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_time_precision_is_not_invented_and_submitted_time_must_fit_the_interval() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].submitted_at = "2026-09-14T01:30:00.999999Z".parse().unwrap();
|
||||
input.actions[0].orders[0].terminal_at = "2026-09-14T01:30:01.500Z".parse().unwrap();
|
||||
input.actions[0].orders[0].fills[0].observed_at = "2026-09-14T01:30:02Z".parse().unwrap();
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input.actions[0].orders[0].submitted_at = "2026-09-14T01:30:01Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].fills[0].executed_at = "2026-09-14T01:30:00.800Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
input.actions[0].orders[0].fills[0].timestamp_precision = ManualTimestampPrecision::Millisecond;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
input.actions[0].orders[0].fills[0].executed_at =
|
||||
"2026-09-14T01:30:00.800001Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_no_order_outcome_is_distinct_from_unconfirmed_or_unknown_work() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders.clear();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
input.actions[0].outcome = ManualActionOutcome::NoOrdersNeeded;
|
||||
reseal(&mut input);
|
||||
input.validate().unwrap();
|
||||
let mut value = serde_json::to_value(input).unwrap();
|
||||
value["actions"][0]["outcome"] = json!("result_unknown");
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_timezone_and_cutoff_are_required() {
|
||||
let mut value = serde_json::to_value(sample()).unwrap();
|
||||
value["actions"][0]["orders"][0]["fills"][0]["executedAt"] = json!("2026-09-14T09:30:00");
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
|
||||
let mut input = sample();
|
||||
input.observation_cutoff = "2026-09-14T01:30:00.700Z".parse().unwrap();
|
||||
assert!(semantic_result(&input).is_err());
|
||||
let mut value = serde_json::to_value(sample()).unwrap();
|
||||
value["actions"][0]["orders"][0]["fills"][0]["commission"] = Value::Null;
|
||||
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_any_external_price_or_identity_invalidates_the_frozen_trace() {
|
||||
let input = sample();
|
||||
let original = input.content_sha256.clone();
|
||||
let mut changed = input.clone();
|
||||
changed.actions[0].orders[0].fills[0].price += Decimal::ONE;
|
||||
assert_ne!(changed.content_digest().unwrap(), original);
|
||||
assert_eq!(
|
||||
changed.validate().unwrap_err(),
|
||||
"manual replay content digest mismatch"
|
||||
);
|
||||
let mut changed = input;
|
||||
changed.account_id = "another-account".into();
|
||||
assert_ne!(changed.content_digest().unwrap(), original);
|
||||
assert!(changed.validate().is_err());
|
||||
}
|
||||
|
||||
fn identity_data(listed: NaiveDate) -> DataSet {
|
||||
DataSet::from_components(
|
||||
vec![crate::Instrument {
|
||||
symbol: "000001.SZ".into(),
|
||||
name: "test".into(),
|
||||
board: "SZ".into(),
|
||||
round_lot: 100,
|
||||
listed_at: Some(listed),
|
||||
delisted_at: None,
|
||||
status: "active".into(),
|
||||
}],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![crate::BenchmarkSnapshot {
|
||||
date: listed,
|
||||
benchmark: "000300.SH".into(),
|
||||
open: 100.,
|
||||
close: 100.,
|
||||
prev_close: 100.,
|
||||
volume: 0,
|
||||
}],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_manual_fill_changes_cash_and_lots_but_not_external_cash_flow_units() {
|
||||
let input = sample();
|
||||
let observations = input.observations().unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
let applied = observations[0].apply(&mut account, &data, false).unwrap();
|
||||
assert_eq!(
|
||||
applied.gross,
|
||||
FixedMoney::from_decimal_str("1012.345679").unwrap()
|
||||
);
|
||||
assert_eq!(applied.fees, FixedMoney::from_decimal_str("0.12").unwrap());
|
||||
assert_eq!(account.cash(), 8987.534321);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
assert_eq!(
|
||||
account
|
||||
.position("000001.SZ")
|
||||
.unwrap()
|
||||
.sellable_qty(input.actions[0].orders[0].fills[0].trade_date),
|
||||
0
|
||||
);
|
||||
assert_eq!(account.external_cash_flow_total(), 0.);
|
||||
assert_eq!(account.starting_cash(), 10_000.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_mismatches_are_atomic_and_do_not_borrow_shares_cash_or_override_pending_orders() {
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let input = sample();
|
||||
let observations = input.observations().unwrap();
|
||||
let mut poor = PortfolioState::new(10.);
|
||||
assert!(observations[0].apply(&mut poor, &data, false).is_err());
|
||||
assert_eq!(poor.cash(), 10.);
|
||||
assert!(poor.positions().is_empty());
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
assert!(observations[0].apply(&mut account, &data, true).is_err());
|
||||
assert_eq!(account.cash(), 10_000.);
|
||||
assert!(account.positions().is_empty());
|
||||
observations[0].apply(&mut account, &data, false).unwrap();
|
||||
let before = account.cash();
|
||||
let mut sell = input.clone();
|
||||
sell.actions[0].orders[0].side = OrderSide::Sell;
|
||||
reseal(&mut sell);
|
||||
assert!(
|
||||
sell.observations().unwrap()[0]
|
||||
.apply(&mut account, &data, false)
|
||||
.unwrap_err()
|
||||
.contains("T+1")
|
||||
);
|
||||
assert_eq!(account.cash(), before);
|
||||
assert_eq!(account.position("000001.SZ").unwrap().quantity, 100);
|
||||
let unlisted = identity_data(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap());
|
||||
assert!(
|
||||
observations[0]
|
||||
.apply(&mut account, &unlisted, false)
|
||||
.unwrap_err()
|
||||
.contains("lifecycle")
|
||||
);
|
||||
assert_eq!(account.cash(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_next_day_manual_sale_keeps_the_actual_quantity_and_fee_contract() {
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let input = sample();
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
input.observations().unwrap()[0]
|
||||
.apply(&mut account, &data, false)
|
||||
.unwrap();
|
||||
let mut sell = input.clone();
|
||||
let order = &mut sell.actions[0].orders[0];
|
||||
order.side = OrderSide::Sell;
|
||||
order.submitted_at += chrono::Duration::days(1);
|
||||
order.terminal_at += chrono::Duration::days(1);
|
||||
order.fills[0].trade_date = order.fills[0].trade_date.succ_opt().unwrap();
|
||||
order.fills[0].executed_at += chrono::Duration::days(1);
|
||||
order.fills[0].observed_at += chrono::Duration::days(1);
|
||||
sell.observation_cutoff += chrono::Duration::days(1);
|
||||
reseal(&mut sell);
|
||||
let applied = sell.observations().unwrap()[0]
|
||||
.apply(&mut account, &data, false)
|
||||
.unwrap();
|
||||
assert_eq!(applied.quantity_after, 0);
|
||||
assert_eq!(account.cash(), 9999.76);
|
||||
assert_eq!(account.external_cash_flow_total(), 0.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn observations_follow_durable_receipt_order_and_not_input_array_order() {
|
||||
let mut input = sample();
|
||||
let mut second = input.actions[0].orders[0].fills[0].clone();
|
||||
second.trade_id = "trade-2".into();
|
||||
second.observation_event_id = "received-2".into();
|
||||
second.observation_sequence = 2;
|
||||
input.actions[0].orders[0].quantity = 200;
|
||||
input.actions[0].orders[0].fills.insert(0, second);
|
||||
reseal(&mut input);
|
||||
assert_eq!(
|
||||
input
|
||||
.observations()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|row| row.fill.observation_sequence)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![1, 2]
|
||||
);
|
||||
let mut invalid = input.clone();
|
||||
invalid.actions[0].orders[0].fills[0].observation_sequence = 1;
|
||||
assert!(
|
||||
semantic_result(&invalid)
|
||||
.unwrap_err()
|
||||
.contains("observation")
|
||||
);
|
||||
let mut invalid = input;
|
||||
invalid.actions[0].orders[0].fills[0].observation_event_id = "received-1".into();
|
||||
assert!(
|
||||
semantic_result(&invalid)
|
||||
.unwrap_err()
|
||||
.contains("observation")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_cancel_is_valid_but_full_fill_cannot_be_reported_as_cancelled() {
|
||||
let mut input = sample();
|
||||
input.actions[0].orders[0].quantity = 200;
|
||||
input.actions[0].orders[0].terminal_status = ManualOrderTerminalStatus::Cancelled;
|
||||
semantic_result(&input).unwrap();
|
||||
input.actions[0].orders[0].quantity = 100;
|
||||
assert!(
|
||||
semantic_result(&input)
|
||||
.unwrap_err()
|
||||
.contains("terminal status")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_waits_for_observation_and_never_reapplies_or_rewinds() {
|
||||
let input = sample();
|
||||
let at = input.actions[0].orders[0].fills[0].observed_at;
|
||||
let mut replay = ManualReplayCursor::new(input).unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut account = PortfolioState::new(10_000.);
|
||||
assert_eq!(replay.next_observation_at(), Some(at));
|
||||
assert!(
|
||||
replay
|
||||
.advance(
|
||||
at - chrono::Duration::milliseconds(1),
|
||||
&mut account,
|
||||
&data,
|
||||
false
|
||||
)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.cash(), 10_000.);
|
||||
let records = replay.advance(at, &mut account, &data, false).unwrap();
|
||||
assert_eq!(records.len(), 1);
|
||||
assert_eq!(records[0].cash_delta, "-1012.465679");
|
||||
assert_eq!(replay.applied_count(), 1);
|
||||
assert_eq!(replay.next_observation_at(), None);
|
||||
let cash = account.cash();
|
||||
assert!(
|
||||
replay
|
||||
.advance(at, &mut account, &data, false)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(account.cash(), cash);
|
||||
assert!(
|
||||
replay
|
||||
.advance(
|
||||
at - chrono::Duration::seconds(1),
|
||||
&mut account,
|
||||
&data,
|
||||
false
|
||||
)
|
||||
.unwrap_err()
|
||||
.contains("backwards")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_multi_receipt_advance_keeps_both_progress_and_portfolio_unchanged() {
|
||||
let mut input = sample();
|
||||
let mut next = input.actions[0].orders[0].fills[0].clone();
|
||||
next.trade_id = "trade-2".into();
|
||||
next.observation_event_id = "received-2".into();
|
||||
next.observation_sequence = 2;
|
||||
input.actions[0].orders[0].quantity = 200;
|
||||
input.actions[0].orders[0].fills.push(next);
|
||||
reseal(&mut input);
|
||||
let at = input.actions[0].orders[0].fills[0].observed_at;
|
||||
let mut replay = ManualReplayCursor::new(input).unwrap();
|
||||
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
|
||||
let mut account = PortfolioState::new(1_500.);
|
||||
assert!(replay.advance(at, &mut account, &data, false).is_err());
|
||||
assert_eq!(account.cash(), 1_500.);
|
||||
assert!(account.positions().is_empty());
|
||||
assert_eq!(replay.applied_count(), 0);
|
||||
assert_eq!(replay.next_observation_at(), Some(at));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fixed_money_decimal_text_preserves_micro_units_without_float_conversion() {
|
||||
for text in [
|
||||
"0",
|
||||
"100",
|
||||
"-100",
|
||||
"0.000001",
|
||||
"-0.000001",
|
||||
"12345678901234567890123456.123456",
|
||||
] {
|
||||
assert_eq!(
|
||||
FixedMoney::from_decimal_str(text)
|
||||
.unwrap()
|
||||
.to_decimal_string(),
|
||||
text
|
||||
);
|
||||
}
|
||||
let min = FixedMoney::from_raw(i128::MIN);
|
||||
assert!(min.to_decimal_string().starts_with('-'));
|
||||
}
|
||||
Reference in New Issue
Block a user