完善手工回放的最终费用和真实观察时间合同

This commit is contained in:
boris
2026-09-14 10:15:03 +08:00
parent a29c434be9
commit 665653c3fe
3 changed files with 216 additions and 56 deletions
+98 -13
View File
@@ -2,16 +2,18 @@ use super::*;
use serde_json::{Value, json};
fn sample() -> ManualExecutionReplay {
let fill = json!({"tradeId":"trade-1","observationEventId":"received-1","observationSequence":1,"tradeDate":"2026-09-14","executedAt":"2026-09-14T01:30:00Z",
"feeObservationEventId":"received-1","feeObservationSequence":1,"feeObservedAt":"2026-09-14T01:30:01Z",
"observedAt":"2026-09-14T01:30:01Z","timestampPrecision":"second","quantity":100,
"price":"10.1234567891","commission":"0.1000001","stampTax":"0","transferFee":"0.02","totalFee":"0.1200001"});
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":[{
"confirmedAt":"2026-09-14T01:30:00.500Z","confirmationObservedAt":"2026-09-14T01:30:00.550Z","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"}]
"orderCreatedAt":"2026-09-14T01:30:00.600Z","terminalObservedAt":"2026-09-14T01:30:00.900Z","terminalStatus":"filled",
"fills":[fill]
}]}]
})).unwrap();
reseal(&mut input);
@@ -43,7 +45,7 @@ fn complete_exact_decimal_evidence_allows_later_observation_and_retains_source_d
#[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"] {
for field in ["price", "totalFee"] {
let mut missing = original.clone();
missing["actions"][0]["orders"][0]["fills"][0]
.as_object_mut()
@@ -96,7 +98,7 @@ fn inconsistent_counts_terminals_audits_and_duplicate_facts_are_rejected() {
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();
invalid.actions[0].orders[0].source_adapter = Some("paper".into());
reseal(&mut invalid);
invalid.validate().unwrap();
}
@@ -104,12 +106,14 @@ fn inconsistent_counts_terminals_audits_and_duplicate_facts_are_rejected() {
#[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].order_created_at = "2026-09-14T01:30:00.999999Z".parse().unwrap();
input.actions[0].orders[0].terminal_observed_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();
input.actions[0].orders[0].fills[0].fee_observed_at =
input.actions[0].orders[0].fills[0].observed_at;
reseal(&mut input);
input.validate().unwrap();
input.actions[0].orders[0].submitted_at = "2026-09-14T01:30:01Z".parse().unwrap();
input.actions[0].orders[0].order_created_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();
@@ -130,6 +134,9 @@ fn confirmed_no_order_outcome_is_distinct_from_unconfirmed_or_unknown_work() {
input.actions[0].outcome = ManualActionOutcome::NoOrdersNeeded;
reseal(&mut input);
input.validate().unwrap();
input.actions[0].outcome = ManualActionOutcome::NotExecuted;
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());
@@ -144,10 +151,83 @@ fn raw_timezone_and_cutoff_are_required() {
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;
value["actions"][0]["orders"][0]["fills"][0]["totalFee"] = Value::Null;
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err());
}
#[test]
fn authoritative_total_fee_does_not_require_inventing_unknown_components() {
let mut input = sample();
let fill = &mut input.actions[0].orders[0].fills[0];
fill.commission = None;
fill.stamp_tax = None;
fill.transfer_fee = None;
assert_eq!(
fill.total_fees().unwrap(),
"0.1200001".parse::<Decimal>().unwrap()
);
assert!(semantic_result(&input).is_ok());
let value = serde_json::to_value(&input).unwrap();
assert!(value["actions"][0]["orders"][0]["fills"][0]["commission"].is_null());
assert_eq!(
value["actions"][0]["orders"][0]["fills"][0]["totalFee"],
"0.1200001"
);
for field in ["commission", "stampTax", "transferFee"] {
let mut numeric = value.clone();
numeric["actions"][0]["orders"][0]["fills"][0][field] = json!(0.1);
assert!(serde_json::from_value::<ManualExecutionReplay>(numeric).is_err());
}
}
#[test]
fn manual_fee_total_includes_extra_charges_and_rejects_inconsistent_components() {
let mut input = sample();
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
assert!(semantic_result(&input).is_ok());
assert_eq!(
input.actions[0].orders[0].fills[0]
.total_fees()
.unwrap()
.to_string(),
"0.15"
);
input.actions[0].orders[0].fills[0].total_fee = "0.1".parse().unwrap();
assert!(semantic_result(&input).is_err());
input.actions[0].orders[0].fills[0].total_fee = "0.15".parse().unwrap();
input.actions[0].orders[0].fills[0].commission = Some(Decimal::NEGATIVE_ONE);
assert!(semantic_result(&input).is_err());
}
#[test]
fn late_fee_evidence_keeps_the_original_fill_observation_clock() {
let data = identity_data(NaiveDate::from_ymd_opt(2020, 1, 1).unwrap());
let mut input = sample();
let fill = &mut input.actions[0].orders[0].fills[0];
let original = fill.observed_at;
fill.fee_observation_event_id = "fee-receipt-1".into();
fill.fee_observation_sequence = 2;
fill.fee_observed_at = original + chrono::Duration::hours(1);
let fee_time = fill.fee_observed_at;
reseal(&mut input);
let mut cursor = ManualReplayCursor::new(input).unwrap();
assert_eq!(cursor.next_observation_at(), Some(original));
let mut portfolio = PortfolioState::new(10_000.);
let result = cursor
.advance(original, &mut portfolio, &data, false)
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].observed_at, original);
assert_eq!(result[0].fee_observed_at, fee_time);
assert_eq!(result[0].source_total_fee, "0.1200001");
assert!(
cursor
.advance(fee_time, &mut portfolio, &data, false)
.unwrap()
.is_empty()
);
}
#[test]
fn changing_any_external_price_or_identity_invalidates_the_frozen_trace() {
let input = sample();
@@ -263,11 +343,12 @@ fn the_next_day_manual_sale_keeps_the_actual_quantity_and_fee_contract() {
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.order_created_at += chrono::Duration::days(1);
order.terminal_observed_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);
order.fills[0].fee_observed_at += chrono::Duration::days(1);
sell.observation_cutoff += chrono::Duration::days(1);
reseal(&mut sell);
let applied = sell.observations().unwrap()[0]
@@ -285,6 +366,8 @@ fn observations_follow_durable_receipt_order_and_not_input_array_order() {
second.trade_id = "trade-2".into();
second.observation_event_id = "received-2".into();
second.observation_sequence = 2;
second.fee_observation_event_id = "received-2".into();
second.fee_observation_sequence = 2;
input.actions[0].orders[0].quantity = 200;
input.actions[0].orders[0].fills.insert(0, second);
reseal(&mut input);
@@ -380,6 +463,8 @@ fn failed_multi_receipt_advance_keeps_both_progress_and_portfolio_unchanged() {
next.trade_id = "trade-2".into();
next.observation_event_id = "received-2".into();
next.observation_sequence = 2;
next.fee_observation_event_id = "received-2".into();
next.fee_observation_sequence = 2;
input.actions[0].orders[0].quantity = 200;
input.actions[0].orders[0].fills.push(next);
reseal(&mut input);