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

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
+108 -41
View File
@@ -1,7 +1,7 @@
//! Confirmed manual fills are external observations, not simulated broker fills. //! Confirmed manual fills are external observations, not simulated broker fills.
//! The producer must bind these records to the runtime's durable order/audit facts. //! The producer must bind these records to the runtime's durable order/audit facts.
use std::collections::BTreeSet; use std::collections::{BTreeMap, BTreeSet};
use chrono::{DateTime, FixedOffset, NaiveDate, Timelike, Utc}; use chrono::{DateTime, FixedOffset, NaiveDate, Timelike, Utc};
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -12,7 +12,7 @@ use crate::events::OrderSide;
use crate::{DataSet, FixedMoney, PortfolioState}; use crate::{DataSet, FixedMoney, PortfolioState};
use rust_decimal::prelude::ToPrimitive; use rust_decimal::prelude::ToPrimitive;
pub const MANUAL_REPLAY_SCHEMA: &str = "fidc.observed-manual-executions/v1"; pub const MANUAL_REPLAY_SCHEMA: &str = "fidc.observed-manual-executions/v2";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields, rename_all = "camelCase")] #[serde(deny_unknown_fields, rename_all = "camelCase")]
@@ -33,6 +33,7 @@ pub struct ManualExecutionAction {
pub source: ManualExecutionSource, pub source: ManualExecutionSource,
pub audit_event_ids: Vec<String>, pub audit_event_ids: Vec<String>,
pub confirmed_at: DateTime<Utc>, pub confirmed_at: DateTime<Utc>,
pub confirmation_observed_at: DateTime<Utc>,
pub outcome: ManualActionOutcome, pub outcome: ManualActionOutcome,
pub orders: Vec<ManualExecutionOrder>, pub orders: Vec<ManualExecutionOrder>,
} }
@@ -41,6 +42,7 @@ pub struct ManualExecutionAction {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum ManualActionOutcome { pub enum ManualActionOutcome {
NoOrdersNeeded, NoOrdersNeeded,
NotExecuted,
OrdersTerminal, OrdersTerminal,
} }
@@ -58,12 +60,12 @@ pub enum ManualExecutionSource {
pub struct ManualExecutionOrder { pub struct ManualExecutionOrder {
pub order_id: String, pub order_id: String,
pub broker_order_id: Option<String>, pub broker_order_id: Option<String>,
pub source_adapter: String, pub source_adapter: Option<String>,
pub symbol: String, pub symbol: String,
pub side: OrderSide, pub side: OrderSide,
pub quantity: u32, pub quantity: u32,
pub submitted_at: DateTime<Utc>, pub order_created_at: DateTime<Utc>,
pub terminal_at: DateTime<Utc>, pub terminal_observed_at: DateTime<Utc>,
pub terminal_status: ManualOrderTerminalStatus, pub terminal_status: ManualOrderTerminalStatus,
pub fills: Vec<ManualExecutionFill>, pub fills: Vec<ManualExecutionFill>,
} }
@@ -83,6 +85,9 @@ pub struct ManualExecutionFill {
pub trade_id: String, pub trade_id: String,
pub observation_event_id: String, pub observation_event_id: String,
pub observation_sequence: u64, pub observation_sequence: u64,
pub fee_observation_event_id: String,
pub fee_observation_sequence: u64,
pub fee_observed_at: DateTime<Utc>,
pub trade_date: NaiveDate, pub trade_date: NaiveDate,
pub executed_at: DateTime<Utc>, pub executed_at: DateTime<Utc>,
pub observed_at: DateTime<Utc>, pub observed_at: DateTime<Utc>,
@@ -90,12 +95,15 @@ pub struct ManualExecutionFill {
pub quantity: u32, pub quantity: u32,
#[serde(with = "rust_decimal::serde::str")] #[serde(with = "rust_decimal::serde::str")]
pub price: Decimal, pub price: Decimal,
#[serde(default, with = "rust_decimal::serde::str_option")]
pub commission: Option<Decimal>,
#[serde(default, with = "rust_decimal::serde::str_option")]
pub stamp_tax: Option<Decimal>,
#[serde(default, with = "rust_decimal::serde::str_option")]
pub transfer_fee: Option<Decimal>,
/// Full observed charge, including any venue fees not itemized above.
#[serde(with = "rust_decimal::serde::str")] #[serde(with = "rust_decimal::serde::str")]
pub commission: Decimal, pub total_fee: Decimal,
#[serde(with = "rust_decimal::serde::str")]
pub stamp_tax: Decimal,
#[serde(with = "rust_decimal::serde::str")]
pub transfer_fee: Decimal,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -126,10 +134,19 @@ impl ManualExecutionFill {
} }
pub fn total_fees(&self) -> Result<Decimal, String> { pub fn total_fees(&self) -> Result<Decimal, String> {
self.commission let known = [self.commission, self.stamp_tax, self.transfer_fee]
.checked_add(self.stamp_tax) .into_iter()
.and_then(|sum| sum.checked_add(self.transfer_fee)) .flatten()
.ok_or_else(|| "manual fill fees overflow".into()) .try_fold(Decimal::ZERO, |sum, fee| {
if fee < Decimal::ZERO {
return Err("manual fill fee component is negative");
}
sum.checked_add(fee).ok_or("manual fill fees overflow")
})?;
if self.total_fee < known {
return Err("manual total fee is below its known components".into());
}
Ok(self.total_fee)
} }
} }
@@ -200,17 +217,22 @@ impl ManualExecutionReplay {
let mut trades = BTreeSet::new(); let mut trades = BTreeSet::new();
let mut observation_events = BTreeSet::new(); let mut observation_events = BTreeSet::new();
let mut observation_sequences = BTreeSet::new(); let mut observation_sequences = BTreeSet::new();
let mut fee_observations = BTreeSet::new();
let mut receipt_ids = BTreeMap::new();
let mut receipt_sequences = BTreeMap::new();
for action in &self.actions { for action in &self.actions {
identifier(&action.action_id)?; identifier(&action.action_id)?;
if !actions.insert(action.action_id.as_str()) if !actions.insert(action.action_id.as_str())
|| action.confirmed_at > self.observation_cutoff || action.confirmed_at > self.observation_cutoff
|| action.confirmation_observed_at < action.confirmed_at
|| action.confirmation_observed_at > self.observation_cutoff
{ {
return Err("duplicate manual action or confirmation after cutoff".into()); return Err("duplicate manual action or confirmation after cutoff".into());
} }
if action.audit_event_ids.is_empty() { if action.audit_event_ids.is_empty() {
return Err("manual action has no immutable audit binding".into()); return Err("manual action has no immutable audit binding".into());
} }
if (action.outcome == ManualActionOutcome::NoOrdersNeeded) != action.orders.is_empty() { if (action.outcome != ManualActionOutcome::OrdersTerminal) != action.orders.is_empty() {
return Err("manual action outcome does not prove its order coverage".into()); return Err("manual action outcome does not prove its order coverage".into());
} }
for id in &action.audit_event_ids { for id in &action.audit_event_ids {
@@ -221,20 +243,28 @@ impl ManualExecutionReplay {
} }
for order in &action.orders { for order in &action.orders {
identifier(&order.order_id)?; identifier(&order.order_id)?;
identifier(&order.source_adapter)?; if let Some(adapter) = &order.source_adapter {
identifier(adapter)?;
}
identifier(&order.symbol)?; identifier(&order.symbol)?;
if let Some(id) = &order.broker_order_id { if let Some(id) = &order.broker_order_id {
identifier(id)?; identifier(id)?;
if !broker_orders.insert(( if !broker_orders.insert((
order.source_adapter.as_str(), order
order.submitted_at.with_timezone(&shanghai).date_naive(), .source_adapter
.as_deref()
.ok_or("broker identity requires its source adapter")?,
order.order_created_at.with_timezone(&shanghai).date_naive(),
id.as_str(), id.as_str(),
)) { )) {
return Err("manual local orders share one broker order identity".into()); return Err("manual local orders share one broker order identity".into());
} }
} }
if !order.fills.is_empty() && order.source_adapter.is_none() {
return Err("manual fills require a known source adapter".into());
}
if !order.fills.is_empty() if !order.fills.is_empty()
&& order.source_adapter != "paper" && order.source_adapter.as_deref() != Some("paper")
&& order.broker_order_id.is_none() && order.broker_order_id.is_none()
{ {
return Err( return Err(
@@ -247,9 +277,9 @@ impl ManualExecutionReplay {
{ {
return Err("duplicate manual order or invalid quantity".into()); return Err("duplicate manual order or invalid quantity".into());
} }
if order.submitted_at < action.confirmed_at if order.order_created_at < action.confirmed_at
|| order.terminal_at < order.submitted_at || order.terminal_observed_at < order.order_created_at
|| order.terminal_at > self.observation_cutoff || order.terminal_observed_at > self.observation_cutoff
{ {
return Err( return Err(
"manual order confirmation/submission/terminal time is inconsistent".into(), "manual order confirmation/submission/terminal time is inconsistent".into(),
@@ -259,6 +289,7 @@ impl ManualExecutionReplay {
for fill in &order.fills { for fill in &order.fills {
identifier(&fill.trade_id)?; identifier(&fill.trade_id)?;
identifier(&fill.observation_event_id)?; identifier(&fill.observation_event_id)?;
identifier(&fill.fee_observation_event_id)?;
if fill.observation_sequence == 0 if fill.observation_sequence == 0
|| fill.observation_sequence > i64::MAX as u64 || fill.observation_sequence > i64::MAX as u64
|| !observation_events.insert(fill.observation_event_id.as_str()) || !observation_events.insert(fill.observation_event_id.as_str())
@@ -269,16 +300,52 @@ impl ManualExecutionReplay {
.into(), .into(),
); );
} }
if fill.fee_observation_sequence == 0
|| fill.fee_observation_sequence > i64::MAX as u64
|| fill.fee_observed_at < fill.observed_at
|| fill.fee_observed_at > self.observation_cutoff
|| !fee_observations.insert((
fill.fee_observation_event_id.as_str(),
fill.fee_observation_sequence,
))
{
return Err("manual finalized fees require their own unique observation within the cutoff".into());
}
if (fill.fee_observation_event_id == fill.observation_event_id)
!= (fill.fee_observation_sequence == fill.observation_sequence)
|| (fill.fee_observation_event_id == fill.observation_event_id
&& fill.fee_observed_at != fill.observed_at)
{
return Err("manual fill and fee observation identities disagree".into());
}
if !trades.insert((fill.trade_date, fill.trade_id.as_str())) if !trades.insert((fill.trade_date, fill.trade_id.as_str()))
|| fill.quantity == 0 || fill.quantity == 0
{ {
return Err("duplicate manual trade or zero fill quantity".into()); return Err("duplicate manual trade or zero fill quantity".into());
} }
for (id, sequence) in [
(&fill.observation_event_id, fill.observation_sequence),
(
&fill.fee_observation_event_id,
fill.fee_observation_sequence,
),
] {
if receipt_ids
.insert(id, (&fill.trade_id, sequence))
.is_some_and(|owner| owner != (&fill.trade_id, sequence))
|| receipt_sequences
.insert(sequence, (&fill.trade_id, id))
.is_some_and(|owner| owner != (&fill.trade_id, id))
{
return Err("manual observation identity is reused by a different trade or sequence".into());
}
}
if fill.executed_at.with_timezone(&shanghai).date_naive() != fill.trade_date if fill.executed_at.with_timezone(&shanghai).date_naive() != fill.trade_date
|| fill.observed_at > self.observation_cutoff || fill.observed_at > self.observation_cutoff
|| fill.observed_at < order.submitted_at || fill.observed_at < order.order_created_at
|| fill.observed_at < action.confirmation_observed_at
|| fill.observed_at < fill.executed_at || fill.observed_at < fill.executed_at
|| fill.executed_at > order.terminal_at || fill.executed_at > order.terminal_observed_at
{ {
return Err("manual fill execution/observation time is inconsistent".into()); return Err("manual fill execution/observation time is inconsistent".into());
} }
@@ -297,18 +364,12 @@ impl ManualExecutionReplay {
fill.timestamp_precision.nanoseconds(), fill.timestamp_precision.nanoseconds(),
)) ))
.ok_or("manual execution timestamp overflow")?; .ok_or("manual execution timestamp overflow")?;
if fill.executed_at < order.submitted_at && order.submitted_at >= upper { let earliest = order.order_created_at.max(action.confirmation_observed_at);
return Err("manual fill predates its submitted order".into()); if fill.executed_at < earliest && earliest >= upper {
return Err("manual fill predates its order or durable confirmation".into());
} }
if fill.price <= Decimal::ZERO if fill.price <= Decimal::ZERO {
|| [fill.commission, fill.stamp_tax, fill.transfer_fee] return Err("manual fill requires a positive price".into());
.iter()
.any(|fee| *fee < Decimal::ZERO)
{
return Err(
"manual fill requires a positive price and complete nonnegative fees"
.into(),
);
} }
fill.gross_amount()? fill.gross_amount()?
.checked_add(fill.total_fees()?) .checked_add(fill.total_fees()?)
@@ -367,15 +428,18 @@ pub struct ManualReplayApplication {
pub observation_event_id: String, pub observation_event_id: String,
pub observation_sequence: u64, pub observation_sequence: u64,
pub observed_at: DateTime<Utc>, pub observed_at: DateTime<Utc>,
pub fee_observation_event_id: String,
pub fee_observed_at: DateTime<Utc>,
pub executed_at: DateTime<Utc>, pub executed_at: DateTime<Utc>,
pub symbol: String, pub symbol: String,
pub side: OrderSide, pub side: OrderSide,
pub quantity: u32, pub quantity: u32,
pub quantity_after: u32, pub quantity_after: u32,
pub price: String, pub price: String,
pub commission: String, pub commission: Option<String>,
pub stamp_tax: String, pub stamp_tax: Option<String>,
pub transfer_fee: String, pub transfer_fee: Option<String>,
pub source_total_fee: String,
pub source_gross_amount: String, pub source_gross_amount: String,
pub ledger_gross_amount: String, pub ledger_gross_amount: String,
pub ledger_fees: String, pub ledger_fees: String,
@@ -458,15 +522,18 @@ impl ManualReplayCursor {
observation_event_id: fill.observation_event_id.clone(), observation_event_id: fill.observation_event_id.clone(),
observation_sequence: fill.observation_sequence, observation_sequence: fill.observation_sequence,
observed_at: fill.observed_at, observed_at: fill.observed_at,
fee_observation_event_id: fill.fee_observation_event_id.clone(),
fee_observed_at: fill.fee_observed_at,
executed_at: fill.executed_at, executed_at: fill.executed_at,
symbol: order.symbol.clone(), symbol: order.symbol.clone(),
side: order.side, side: order.side,
quantity: fill.quantity, quantity: fill.quantity,
quantity_after: applied.quantity_after, quantity_after: applied.quantity_after,
price: fill.price.to_string(), price: fill.price.to_string(),
commission: fill.commission.to_string(), commission: fill.commission.map(|fee| fee.to_string()),
stamp_tax: fill.stamp_tax.to_string(), stamp_tax: fill.stamp_tax.map(|fee| fee.to_string()),
transfer_fee: fill.transfer_fee.to_string(), transfer_fee: fill.transfer_fee.map(|fee| fee.to_string()),
source_total_fee: fill.total_fee.to_string(),
source_gross_amount: fill.gross_amount()?.to_string(), source_gross_amount: fill.gross_amount()?.to_string(),
ledger_gross_amount: applied.gross.to_decimal_string(), ledger_gross_amount: applied.gross.to_decimal_string(),
ledger_fees: applied.fees.to_decimal_string(), ledger_fees: applied.fees.to_decimal_string(),
+98 -13
View File
@@ -2,16 +2,18 @@ use super::*;
use serde_json::{Value, json}; use serde_json::{Value, json};
fn sample() -> ManualExecutionReplay { 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!({ let mut input:ManualExecutionReplay=serde_json::from_value(json!({
"schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"runtime-1","accountId":"account-1", "schema":MANUAL_REPLAY_SCHEMA,"runtimeId":"runtime-1","accountId":"account-1",
"sourceContractSha256":"a".repeat(64),"contentSha256":"", "observationCutoff":"2026-09-14T08:00:00Z", "sourceContractSha256":"a".repeat(64),"contentSha256":"", "observationCutoff":"2026-09-14T08:00:00Z",
"actions":[{"actionId":"action-1","source":"manual_security_trade","auditEventIds":["audit-1"], "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, "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", "orderCreatedAt":"2026-09-14T01:30:00.600Z","terminalObservedAt":"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", "fills":[fill]
"observedAt":"2026-09-14T01:30:01Z","timestampPrecision":"second","quantity":100,
"price":"10.1234567891","commission":"0.1000001","stampTax":"0","transferFee":"0.02"}]
}]}] }]}]
})).unwrap(); })).unwrap();
reseal(&mut input); reseal(&mut input);
@@ -43,7 +45,7 @@ fn complete_exact_decimal_evidence_allows_later_observation_and_retains_source_d
#[test] #[test]
fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() { fn all_required_money_and_binding_fields_reject_missing_or_wrong_values() {
let original = serde_json::to_value(sample()).unwrap(); 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(); let mut missing = original.clone();
missing["actions"][0]["orders"][0]["fills"][0] missing["actions"][0]["orders"][0]["fills"][0]
.as_object_mut() .as_object_mut()
@@ -96,7 +98,7 @@ fn inconsistent_counts_terminals_audits_and_duplicate_facts_are_rejected() {
let mut invalid = original.clone(); let mut invalid = original.clone();
invalid.actions[0].orders[0].broker_order_id = None; invalid.actions[0].orders[0].broker_order_id = None;
assert!(semantic_result(&invalid).is_err()); 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); reseal(&mut invalid);
invalid.validate().unwrap(); invalid.validate().unwrap();
} }
@@ -104,12 +106,14 @@ fn inconsistent_counts_terminals_audits_and_duplicate_facts_are_rejected() {
#[test] #[test]
fn source_time_precision_is_not_invented_and_submitted_time_must_fit_the_interval() { fn source_time_precision_is_not_invented_and_submitted_time_must_fit_the_interval() {
let mut input = sample(); let mut input = sample();
input.actions[0].orders[0].submitted_at = "2026-09-14T01:30:00.999999Z".parse().unwrap(); input.actions[0].orders[0].order_created_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].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].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); reseal(&mut input);
input.validate().unwrap(); 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()); assert!(semantic_result(&input).is_err());
let mut input = sample(); let mut input = sample();
input.actions[0].orders[0].fills[0].executed_at = "2026-09-14T01:30:00.800Z".parse().unwrap(); 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; input.actions[0].outcome = ManualActionOutcome::NoOrdersNeeded;
reseal(&mut input); reseal(&mut input);
input.validate().unwrap(); input.validate().unwrap();
input.actions[0].outcome = ManualActionOutcome::NotExecuted;
reseal(&mut input);
input.validate().unwrap();
let mut value = serde_json::to_value(input).unwrap(); let mut value = serde_json::to_value(input).unwrap();
value["actions"][0]["outcome"] = json!("result_unknown"); value["actions"][0]["outcome"] = json!("result_unknown");
assert!(serde_json::from_value::<ManualExecutionReplay>(value).is_err()); 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(); input.observation_cutoff = "2026-09-14T01:30:00.700Z".parse().unwrap();
assert!(semantic_result(&input).is_err()); assert!(semantic_result(&input).is_err());
let mut value = serde_json::to_value(sample()).unwrap(); 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()); 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] #[test]
fn changing_any_external_price_or_identity_invalidates_the_frozen_trace() { fn changing_any_external_price_or_identity_invalidates_the_frozen_trace() {
let input = sample(); 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 mut sell = input.clone();
let order = &mut sell.actions[0].orders[0]; let order = &mut sell.actions[0].orders[0];
order.side = OrderSide::Sell; order.side = OrderSide::Sell;
order.submitted_at += chrono::Duration::days(1); order.order_created_at += chrono::Duration::days(1);
order.terminal_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].trade_date = order.fills[0].trade_date.succ_opt().unwrap();
order.fills[0].executed_at += chrono::Duration::days(1); order.fills[0].executed_at += chrono::Duration::days(1);
order.fills[0].observed_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); sell.observation_cutoff += chrono::Duration::days(1);
reseal(&mut sell); reseal(&mut sell);
let applied = sell.observations().unwrap()[0] 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.trade_id = "trade-2".into();
second.observation_event_id = "received-2".into(); second.observation_event_id = "received-2".into();
second.observation_sequence = 2; 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].quantity = 200;
input.actions[0].orders[0].fills.insert(0, second); input.actions[0].orders[0].fills.insert(0, second);
reseal(&mut input); 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.trade_id = "trade-2".into();
next.observation_event_id = "received-2".into(); next.observation_event_id = "received-2".into();
next.observation_sequence = 2; 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].quantity = 200;
input.actions[0].orders[0].fills.push(next); input.actions[0].orders[0].fills.push(next);
reseal(&mut input); reseal(&mut input);
+10 -2
View File
@@ -1,10 +1,18 @@
# 手工成交观察回放:基础合同与当前断点 # 手工成交观察回放:基础合同与当前断点
2026-09-14。本阶段只完成框架基础与本机验证,未接入Runner/API、未发布。生产最近已验收版本仍为v2026.9.14.5;完整Goal和手工影子回放均未完成。 2026-09-14。当前候选已升级v2并与交易端权威读取配套,仍未接入Runner/API或引擎主时钟、未发布。交易最近发布是166998d/v2026.9.14.6,回测仍81acc54/e81;完整Goal和手工影子回放均未完成。
## v2读取合同补充
总费用必须来自权威事实,佣金/印花税/过户费等组件可以未知,不能反过来用已知组件推定费用完整。保留组件原精度、总费用和微元账本费用;未知组件不写成0。新增费用来源事件/序号/可见时刻,原FillReceived继续决定股数变化时刻,后补费用不推迟成交、也不重复入账。历史采用最终费用回放口径,不能声称费用明细当时已经可见。
分别表达订单创建、确认登记、成交、原始观察、费用观察与终态核对,不伪装GT实际发送时间。无订单区分NoOrdersNeeded与NotExecuted;无成交且无券商身份时允许适配器未知,不造名称。确认登记之前的成交、证据跨交易复用、费用少于已知组件及越截止点均拒绝。
最新main a29c434的DayOpen和列存变更已按ff-only保留合入;组合Core860通过,其中本模块18项。交易端读取四类来源及验证范围见fidc-trading-platform/docs/manual-replay-capture-20260914.md。未将整仓无订单、Paper一例与Live一例外推完整参数/时钟/券商验收,不据此解除门禁。
## 已实现 ## 已实现
`manual_execution`提供`fidc.observed-manual-executions/v1`严格合同及`ManualReplayCursor`。这是将已确认的手工成交事实作为外部输入,不是让回测券商独立重演其真实成交。 `manual_execution`提供`fidc.observed-manual-executions/v2`严格合同及`ManualReplayCursor`。这是将已确认的手工成交事实作为外部输入,不是让回测券商独立重演其真实成交。下面保留初版阶段的实现说明,费用和时间字段以本节v2补充为准。
- 保留确认、提交、成交、观察和终态时间,声明秒/毫秒/微秒/纳秒精度;同秒报告只允许在其真实精度区间内与提交时间对应,不伪造纳秒。 - 保留确认、提交、成交、观察和终态时间,声明秒/毫秒/微秒/纳秒精度;同秒报告只允许在其真实精度区间内与提交时间对应,不伪造纳秒。
- 手工动作、审计事件、订单、券商订单、成交和`FillReceived`观察事件/序号均有唯一性与完整性校验。账户/运行身份及源合同摘要进入完整内容SHA;改价格、费用、身份或时间会使旧摘要失效。 - 手工动作、审计事件、订单、券商订单、成交和`FillReceived`观察事件/序号均有唯一性与完整性校验。账户/运行身份及源合同摘要进入完整内容SHA;改价格、费用、身份或时间会使旧摘要失效。