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::(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::(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::(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::(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::(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::(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![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('-')); }