完善手工回放的最终费用和真实观察时间合同
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user