完善手工回放的最终费用和真实观察时间合同
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;改价格、费用、身份或时间会使旧摘要失效。
|
||||||
|
|||||||
Reference in New Issue
Block a user