From 5e11f3da2212ab9dab81bb4a1ebcbb71d5c971bf Mon Sep 17 00:00:00 2001 From: boris Date: Mon, 14 Sep 2026 07:30:19 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BB=BA=E7=AB=8B=E6=89=8B=E5=B7=A5=E6=88=90?= =?UTF-8?q?=E4=BA=A4=E8=A7=82=E5=AF=9F=E5=90=88=E5=90=8C=E4=B8=8E=E5=8E=9F?= =?UTF-8?q?=E5=AD=90=E5=9B=9E=E6=94=BE=E6=B8=B8=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/fidc-core/src/fixed_point.rs | 11 + crates/fidc-core/src/lib.rs | 1 + crates/fidc-core/src/manual_execution.rs | 534 ++++++++++++++++++ .../fidc-core/src/manual_execution/tests.rs | 416 ++++++++++++++ crates/fidc-core/src/portfolio.rs | 138 ++++- docs/manual-replay-foundation-20260914.md | 35 ++ 6 files changed, 1126 insertions(+), 9 deletions(-) create mode 100644 crates/fidc-core/src/manual_execution.rs create mode 100644 crates/fidc-core/src/manual_execution/tests.rs create mode 100644 docs/manual-replay-foundation-20260914.md diff --git a/crates/fidc-core/src/fixed_point.rs b/crates/fidc-core/src/fixed_point.rs index ff309a0..2169ad6 100644 --- a/crates/fidc-core/src/fixed_point.rs +++ b/crates/fidc-core/src/fixed_point.rs @@ -28,6 +28,17 @@ impl FixedMoney { self.0 } + pub fn to_decimal_string(self) -> String { + let magnitude = self.0.unsigned_abs(); + let scale = MONEY_SCALE as u128; + let sign = if self.0 < 0 { "-" } else { "" }; + let width = MONEY_SCALE.ilog10() as usize; + format!("{sign}{}.{:0width$}", magnitude / scale, magnitude % scale) + .trim_end_matches('0') + .trim_end_matches('.') + .to_string() + } + pub fn from_decimal_str(value: &str) -> Result { let value = value.trim(); if value.is_empty() { diff --git a/crates/fidc-core/src/lib.rs b/crates/fidc-core/src/lib.rs index 274eeeb..c3d8a59 100644 --- a/crates/fidc-core/src/lib.rs +++ b/crates/fidc-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod fixed_point; pub mod futures; pub mod instrument; pub mod metrics; +pub mod manual_execution; mod numeric_expr_vm; pub mod platform_expr_strategy; pub mod platform_runtime_schema; diff --git a/crates/fidc-core/src/manual_execution.rs b/crates/fidc-core/src/manual_execution.rs new file mode 100644 index 0000000..733f820 --- /dev/null +++ b/crates/fidc-core/src/manual_execution.rs @@ -0,0 +1,534 @@ +//! Confirmed manual fills are external observations, not simulated broker fills. +//! The producer must bind these records to the runtime's durable order/audit facts. + +use std::collections::BTreeSet; + +use chrono::{DateTime, FixedOffset, NaiveDate, Timelike, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::events::OrderSide; +use crate::{DataSet, FixedMoney, PortfolioState}; +use rust_decimal::prelude::ToPrimitive; + +pub const MANUAL_REPLAY_SCHEMA: &str = "fidc.observed-manual-executions/v1"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManualExecutionReplay { + pub schema: String, + pub runtime_id: String, + pub account_id: String, + pub source_contract_sha256: String, + pub content_sha256: String, + pub observation_cutoff: DateTime, + pub actions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManualExecutionAction { + pub action_id: String, + pub source: ManualExecutionSource, + pub audit_event_ids: Vec, + pub confirmed_at: DateTime, + pub outcome: ManualActionOutcome, + pub orders: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ManualActionOutcome { + NoOrdersNeeded, + OrdersTerminal, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ManualExecutionSource { + ManualSecurityTrade, + ManualPositionAction, + ManualRebalance, + StockPoolAllocation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManualExecutionOrder { + pub order_id: String, + pub broker_order_id: Option, + pub source_adapter: String, + pub symbol: String, + pub side: OrderSide, + pub quantity: u32, + pub submitted_at: DateTime, + pub terminal_at: DateTime, + pub terminal_status: ManualOrderTerminalStatus, + pub fills: Vec, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ManualOrderTerminalStatus { + Filled, + Cancelled, + Rejected, + Expired, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct ManualExecutionFill { + pub trade_id: String, + pub observation_event_id: String, + pub observation_sequence: u64, + pub trade_date: NaiveDate, + pub executed_at: DateTime, + pub observed_at: DateTime, + pub timestamp_precision: ManualTimestampPrecision, + pub quantity: u32, + #[serde(with = "rust_decimal::serde::str")] + pub price: Decimal, + #[serde(with = "rust_decimal::serde::str")] + pub commission: 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)] +#[serde(rename_all = "snake_case")] +pub enum ManualTimestampPrecision { + Second, + Millisecond, + Microsecond, + Nanosecond, +} + +impl ManualTimestampPrecision { + fn nanoseconds(self) -> i64 { + match self { + Self::Second => 1_000_000_000, + Self::Millisecond => 1_000_000, + Self::Microsecond => 1_000, + Self::Nanosecond => 1, + } + } +} + +impl ManualExecutionFill { + pub fn gross_amount(&self) -> Result { + self.price + .checked_mul(Decimal::from(self.quantity)) + .ok_or_else(|| "manual fill gross amount overflow".into()) + } + + pub fn total_fees(&self) -> Result { + self.commission + .checked_add(self.stamp_tax) + .and_then(|sum| sum.checked_add(self.transfer_fee)) + .ok_or_else(|| "manual fill fees overflow".into()) + } +} + +fn identifier(value: &str) -> Result<(), String> { + if value.is_empty() + || value.trim() != value + || value.len() > 256 + || value.chars().any(char::is_control) + { + return Err("manual execution identity is empty, untrimmed or invalid".into()); + } + Ok(()) +} + +impl ManualExecutionReplay { + pub fn observations(&self) -> Result>, String> { + self.validate()?; + let mut observations = Vec::new(); + for action in &self.actions { + for order in &action.orders { + for fill in &order.fills { + observations.push(ManualFillObservation { + action, + order, + fill, + }); + } + } + } + observations.sort_by_key(|entry| (entry.fill.observed_at, entry.fill.observation_sequence)); + Ok(observations) + } + pub fn content_digest(&self) -> Result { + let mut value = serde_json::to_value(self).map_err(|error| error.to_string())?; + value + .as_object_mut() + .ok_or("manual replay is not an object")? + .remove("contentSha256"); + let bytes = serde_json::to_vec(&value).map_err(|error| error.to_string())?; + Ok(format!("{:x}", Sha256::digest(bytes))) + } + + pub fn validate(&self) -> Result<(), String> { + if self.schema != MANUAL_REPLAY_SCHEMA { + return Err("unsupported manual replay schema".into()); + } + identifier(&self.runtime_id)?; + identifier(&self.account_id)?; + if self.source_contract_sha256.len() != 64 + || !self + .source_contract_sha256 + .bytes() + .all(|v| v.is_ascii_hexdigit()) + { + return Err("manual replay source contract hash is invalid".into()); + } + if self.content_digest()? != self.content_sha256 { + return Err("manual replay content digest mismatch".into()); + } + if self.actions.len() > 100_000 { + return Err("manual replay action limit exceeded; trace was not truncated".into()); + } + let shanghai = FixedOffset::east_opt(8 * 3600).unwrap(); + let mut actions = BTreeSet::new(); + let mut audits = BTreeSet::new(); + let mut orders = BTreeSet::new(); + let mut broker_orders = BTreeSet::new(); + let mut trades = BTreeSet::new(); + let mut observation_events = BTreeSet::new(); + let mut observation_sequences = BTreeSet::new(); + for action in &self.actions { + identifier(&action.action_id)?; + if !actions.insert(action.action_id.as_str()) + || action.confirmed_at > self.observation_cutoff + { + return Err("duplicate manual action or confirmation after cutoff".into()); + } + if action.audit_event_ids.is_empty() { + return Err("manual action has no immutable audit binding".into()); + } + if (action.outcome == ManualActionOutcome::NoOrdersNeeded) != action.orders.is_empty() { + return Err("manual action outcome does not prove its order coverage".into()); + } + for id in &action.audit_event_ids { + identifier(id)?; + if !audits.insert(id.as_str()) { + return Err("manual audit event is bound more than once".into()); + } + } + for order in &action.orders { + identifier(&order.order_id)?; + identifier(&order.source_adapter)?; + identifier(&order.symbol)?; + if let Some(id) = &order.broker_order_id { + identifier(id)?; + if !broker_orders.insert(( + order.source_adapter.as_str(), + order.submitted_at.with_timezone(&shanghai).date_naive(), + id.as_str(), + )) { + return Err("manual local orders share one broker order identity".into()); + } + } + if !order.fills.is_empty() + && order.source_adapter != "paper" + && order.broker_order_id.is_none() + { + return Err( + "manual broker fills require their original broker order identity".into(), + ); + } + if !orders.insert(order.order_id.as_str()) + || order.quantity == 0 + || order.quantity > i32::MAX as u32 + { + return Err("duplicate manual order or invalid quantity".into()); + } + if order.submitted_at < action.confirmed_at + || order.terminal_at < order.submitted_at + || order.terminal_at > self.observation_cutoff + { + return Err( + "manual order confirmation/submission/terminal time is inconsistent".into(), + ); + } + let mut filled = 0_u32; + for fill in &order.fills { + identifier(&fill.trade_id)?; + identifier(&fill.observation_event_id)?; + if fill.observation_sequence == 0 + || fill.observation_sequence > i64::MAX as u64 + || !observation_events.insert(fill.observation_event_id.as_str()) + || !observation_sequences.insert(fill.observation_sequence) + { + return Err( + "manual fill requires a unique durable observation event and sequence" + .into(), + ); + } + if !trades.insert((fill.trade_date, fill.trade_id.as_str())) + || fill.quantity == 0 + { + return Err("duplicate manual trade or zero fill quantity".into()); + } + if fill.executed_at.with_timezone(&shanghai).date_naive() != fill.trade_date + || fill.observed_at > self.observation_cutoff + || fill.observed_at < order.submitted_at + || fill.observed_at < fill.executed_at + || fill.executed_at > order.terminal_at + { + return Err("manual fill execution/observation time is inconsistent".into()); + } + if i64::from(fill.executed_at.nanosecond()) + % fill.timestamp_precision.nanoseconds() + != 0 + { + return Err( + "broker timestamp contains digits finer than its declared precision" + .into(), + ); + } + let upper = fill + .executed_at + .checked_add_signed(chrono::Duration::nanoseconds( + fill.timestamp_precision.nanoseconds(), + )) + .ok_or("manual execution timestamp overflow")?; + if fill.executed_at < order.submitted_at && order.submitted_at >= upper { + return Err("manual fill predates its submitted order".into()); + } + if fill.price <= Decimal::ZERO + || [fill.commission, fill.stamp_tax, fill.transfer_fee] + .iter() + .any(|fee| *fee < Decimal::ZERO) + { + return Err( + "manual fill requires a positive price and complete nonnegative fees" + .into(), + ); + } + fill.gross_amount()? + .checked_add(fill.total_fees()?) + .ok_or("manual fill cash amount overflow")?; + filled = filled + .checked_add(fill.quantity) + .ok_or("manual cumulative fill quantity overflow")?; + } + if filled > order.quantity + || (order.terminal_status == ManualOrderTerminalStatus::Filled + && filled != order.quantity) + || (order.terminal_status == ManualOrderTerminalStatus::Rejected && filled != 0) + || (matches!( + order.terminal_status, + ManualOrderTerminalStatus::Cancelled | ManualOrderTerminalStatus::Expired + ) && filled == order.quantity) + { + return Err("manual terminal status disagrees with cumulative fills".into()); + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy)] +pub struct ManualFillObservation<'a> { + pub action: &'a ManualExecutionAction, + pub order: &'a ManualExecutionOrder, + pub fill: &'a ManualExecutionFill, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct AppliedManualFill { + pub gross: FixedMoney, + pub fees: FixedMoney, + pub cash_delta: FixedMoney, + pub quantity_after: u32, +} + +/// One replay owns its immutable trace and progress. Advancing is atomic even +/// if a later receipt in the same step disagrees with the shadow account. +pub struct ManualReplayCursor { + replay: ManualExecutionReplay, + indices: Vec<(usize, usize, usize)>, + cursor: usize, + clock: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ManualReplayApplication { + pub action_id: String, + pub order_id: String, + pub trade_id: String, + pub observation_event_id: String, + pub observation_sequence: u64, + pub observed_at: DateTime, + pub executed_at: DateTime, + pub symbol: String, + pub side: OrderSide, + pub quantity: u32, + pub quantity_after: u32, + pub price: String, + pub commission: String, + pub stamp_tax: String, + pub transfer_fee: String, + pub source_gross_amount: String, + pub ledger_gross_amount: String, + pub ledger_fees: String, + pub cash_delta: String, +} + +impl ManualReplayCursor { + pub fn new(replay: ManualExecutionReplay) -> Result { + replay.validate()?; + let mut indices = Vec::new(); + for (a, action) in replay.actions.iter().enumerate() { + for (o, order) in action.orders.iter().enumerate() { + for f in 0..order.fills.len() { + indices.push((a, o, f)); + } + } + } + indices.sort_by_key(|&(a, o, f)| { + let fill = &replay.actions[a].orders[o].fills[f]; + (fill.observed_at, fill.observation_sequence) + }); + Ok(Self { + replay, + indices, + cursor: 0, + clock: None, + }) + } + + pub fn next_observation_at(&self) -> Option> { + self.indices + .get(self.cursor) + .map(|&(a, o, f)| self.replay.actions[a].orders[o].fills[f].observed_at) + } + + pub fn applied_count(&self) -> usize { + self.cursor + } + + pub fn advance( + &mut self, + at: DateTime, + portfolio: &mut PortfolioState, + data: &DataSet, + has_pending_orders: bool, + ) -> Result, String> { + if at > self.replay.observation_cutoff { + return Err("manual observation clock exceeds the frozen evidence cutoff".into()); + } + if self.clock.is_some_and(|clock| at < clock) { + return Err("manual observation clock moved backwards".into()); + } + let end = self.cursor + + self.indices[self.cursor..] + .iter() + .take_while(|&&(a, o, f)| { + self.replay.actions[a].orders[o].fills[f].observed_at <= at + }) + .count(); + if end == self.cursor { + self.clock = Some(at); + return Ok(vec![]); + } + let mut next = portfolio.clone(); + let mut applications = Vec::with_capacity(end - self.cursor); + for &(a, o, f) in &self.indices[self.cursor..end] { + let action = &self.replay.actions[a]; + let order = &action.orders[o]; + let fill = &order.fills[f]; + let applied = ManualFillObservation { + action, + order, + fill, + } + .apply(&mut next, data, has_pending_orders)?; + applications.push(ManualReplayApplication { + action_id: action.action_id.clone(), + order_id: order.order_id.clone(), + trade_id: fill.trade_id.clone(), + observation_event_id: fill.observation_event_id.clone(), + observation_sequence: fill.observation_sequence, + observed_at: fill.observed_at, + executed_at: fill.executed_at, + symbol: order.symbol.clone(), + side: order.side, + quantity: fill.quantity, + quantity_after: applied.quantity_after, + price: fill.price.to_string(), + commission: fill.commission.to_string(), + stamp_tax: fill.stamp_tax.to_string(), + transfer_fee: fill.transfer_fee.to_string(), + source_gross_amount: fill.gross_amount()?.to_string(), + ledger_gross_amount: applied.gross.to_decimal_string(), + ledger_fees: applied.fees.to_decimal_string(), + cash_delta: applied.cash_delta.to_decimal_string(), + }); + } + *portfolio = next; + self.cursor = end; + self.clock = Some(at); + Ok(applications) + } +} + +impl ManualFillObservation<'_> { + pub(crate) fn apply( + &self, + portfolio: &mut PortfolioState, + data: &DataSet, + has_pending_orders: bool, + ) -> Result { + if has_pending_orders { + return Err("manual observation conflicts with pending shadow orders".into()); + } + let instrument = data + .instrument(&self.order.symbol) + .ok_or("manual observation instrument is absent from frozen source data")?; + if instrument + .dated_market_absence_reason(self.fill.trade_date) + .is_some() + { + return Err("manual execution contradicts the frozen instrument lifecycle".into()); + } + let gross = FixedMoney::from_decimal_str(&self.fill.gross_amount()?.to_string())?; + let fees = FixedMoney::from_decimal_str(&self.fill.total_fees()?.to_string())?; + let price = self + .fill + .price + .to_f64() + .filter(|price| price.is_finite() && *price > 0.) + .ok_or("manual execution price cannot be represented for valuation")?; + // This is the real observed trade price, not a fabricated quote. The + // normal market clock remains responsible for subsequent marks. + let cash_delta = portfolio.apply_observed_manual_fill( + self.fill.trade_date, + &self.order.symbol, + self.order.side, + self.fill.quantity, + price, + price, + gross, + fees, + )?; + Ok(AppliedManualFill { + gross, + fees, + cash_delta, + quantity_after: portfolio + .position(&self.order.symbol) + .map_or(0, |position| position.quantity), + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/fidc-core/src/manual_execution/tests.rs b/crates/fidc-core/src/manual_execution/tests.rs new file mode 100644 index 0000000..0bdb5a2 --- /dev/null +++ b/crates/fidc-core/src/manual_execution/tests.rs @@ -0,0 +1,416 @@ +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('-')); +} diff --git a/crates/fidc-core/src/portfolio.rs b/crates/fidc-core/src/portfolio.rs index 6c786c1..c9de06a 100644 --- a/crates/fidc-core/src/portfolio.rs +++ b/crates/fidc-core/src/portfolio.rs @@ -138,18 +138,28 @@ impl Position { if quantity == 0 { return; } + let gross_amount = fixed_money_or_panic(execution_price * quantity as f64, "position buy gross amount"); + self.buy_with_fixed_gross(date,quantity,execution_price,mark_price,gross_amount); + } + fn buy_with_fixed_gross( + &mut self, + date: NaiveDate, + quantity: u32, + execution_price: f64, + mark_price: f64, + gross_amount: FixedMoney, + ) { let previous_quantity = self.quantity; - self.last_buy_date = Some(self.last_buy_date.map_or(date, |previous| previous.max(date))); + self.last_buy_date = Some( + self.last_buy_date + .map_or(date, |previous| previous.max(date)), + ); if previous_quantity == 0 { self.opened_date = Some(date); } let previous_average_price = self.average_price; let previous_average_cost = self.average_cost; - let gross_amount = fixed_money_or_panic( - execution_price * quantity as f64, - "position buy gross amount", - ); self.lots.push(PositionLot { acquired_date: date, quantity, @@ -200,6 +210,20 @@ impl Position { quantity: u32, execution_price: f64, mark_price: f64, + ) -> Result { + if quantity > self.quantity { + return Err(format!("sell quantity {} exceeds current quantity {} for {}",quantity,self.quantity,self.symbol)); + } + let total_proceeds = fixed_money(execution_price * quantity as f64,"position sell gross amount")?; + self.sell_with_fixed_gross(quantity,execution_price,mark_price,total_proceeds) + } + + fn sell_with_fixed_gross( + &mut self, + quantity: u32, + execution_price: f64, + mark_price: f64, + total_proceeds: FixedMoney, ) -> Result { if quantity > self.quantity { return Err(format!( @@ -208,10 +232,6 @@ impl Position { )); } - let total_proceeds = fixed_money( - execution_price * quantity as f64, - "position sell gross amount", - )?; let mut remaining = quantity; let mut remaining_proceeds = total_proceeds; let mut realized = FixedMoney::ZERO; @@ -796,6 +816,106 @@ impl PortfolioState { Ok(()) } + /// Apply one fully observed external fill atomically. Its money is already + /// quantized from the original decimal amounts, not from a float product. + pub(crate) fn apply_observed_manual_fill( + &mut self, + trade_date: NaiveDate, + symbol: &str, + side: crate::events::OrderSide, + quantity: u32, + price: f64, + mark_price: f64, + gross: FixedMoney, + fees: FixedMoney, + ) -> Result { + use crate::events::OrderSide; + if symbol.trim().is_empty() + || quantity == 0 + || quantity > i32::MAX as u32 + || !price.is_finite() + || price <= 0. + || !mark_price.is_finite() + || mark_price <= 0. + || gross <= FixedMoney::ZERO + || fees < FixedMoney::ZERO + { + return Err("invalid observed manual fill".into()); + } + let mut position = self + .positions + .get(symbol) + .cloned() + .unwrap_or_else(|| Position::new(symbol)); + let delta = match side { + OrderSide::Buy => gross.checked_add(fees).and_then(FixedMoney::checked_neg), + OrderSide::Sell => gross.checked_sub(fees), + } + .ok_or("manual fill cash delta overflow")?; + let next_cash = self + .cash + .checked_add(delta) + .filter(|cash| *cash >= FixedMoney::ZERO) + .ok_or("manual fill disagrees with shadow available cash")?; + let next_cost = position + .day_trade_cost + .checked_add(fees) + .ok_or("manual trade cost overflow")?; + match side { + OrderSide::Buy => { + let total_quantity = position + .quantity + .checked_add(quantity) + .ok_or("manual position quantity overflow")?; + FixedMoney::from_f64(mark_price * f64::from(total_quantity)) + .ok_or("manual marked position value overflow")?; + position + .day_buy_quantity + .checked_add(quantity) + .ok_or("manual daily buy quantity overflow")?; + position + .day_trade_quantity_delta + .checked_add(quantity as i32) + .ok_or("manual daily quantity delta overflow")?; + position + .day_buy_value + .checked_add(gross) + .ok_or("manual daily buy value overflow")?; + let total_basis = gross.checked_add(fees).ok_or("manual lot basis overflow")?; + position + .total_cost_basis() + .checked_add(total_basis) + .ok_or("manual aggregate position basis overflow")?; + position.buy_with_fixed_gross(trade_date, quantity, price, mark_price, gross); + position + .lots + .last_mut() + .ok_or("manual buy produced no lot")? + .cost_basis = total_basis; + position.average_cost += fees.to_f64() / f64::from(position.quantity); + } + OrderSide::Sell => { + if quantity > position.sellable_qty(trade_date) { + return Err("manual fill disagrees with shadow sellable holdings or T+1".into()); + } + position + .day_sell_quantity + .checked_add(quantity) + .ok_or("manual daily sell quantity overflow")?; + position + .day_trade_quantity_delta + .checked_sub(quantity as i32) + .ok_or("manual daily quantity delta overflow")?; + position.sell_with_fixed_gross(quantity, price, mark_price, gross)?; + } + } + position.day_trade_cost = next_cost; + position.refresh_day_pnl(); + self.positions.insert(symbol.to_string(), position); + self.cash = next_cash; + Ok(delta) + } + pub fn prune_flat_positions(&mut self) { let mut sold_symbols = Vec::new(); self.positions.retain(|symbol, position| { diff --git a/docs/manual-replay-foundation-20260914.md b/docs/manual-replay-foundation-20260914.md new file mode 100644 index 0000000..b2721c4 --- /dev/null +++ b/docs/manual-replay-foundation-20260914.md @@ -0,0 +1,35 @@ +# 手工成交观察回放:基础合同与当前断点 + +2026-09-14。本阶段只完成框架基础与本机验证,未接入Runner/API、未发布。生产最近已验收版本仍为v2026.9.14.5;完整Goal和手工影子回放均未完成。 + +## 已实现 + +`manual_execution`提供`fidc.observed-manual-executions/v1`严格合同及`ManualReplayCursor`。这是将已确认的手工成交事实作为外部输入,不是让回测券商独立重演其真实成交。 + +- 保留确认、提交、成交、观察和终态时间,声明秒/毫秒/微秒/纳秒精度;同秒报告只允许在其真实精度区间内与提交时间对应,不伪造纳秒。 +- 手工动作、审计事件、订单、券商订单、成交和`FillReceived`观察事件/序号均有唯一性与完整性校验。账户/运行身份及源合同摘要进入完整内容SHA;改价格、费用、身份或时间会使旧摘要失效。 +- 明确区分无须生成订单与有终态订单,拒绝不完整、未知、超量、状态不一致、超截止日期的数据。不将空订单列表直接当成功。 +- 金额输入使用十进制字符串,不先经过JSON浮点数。保留原价、原费用、原成交额;账本沿用既有微元精度,真实十进制金额在入口统一量化,并分开返回原值和账本值。 +- 游标按真实观察时间和已持久化事件序号前进,重入同一时点不会重复入账,时间倒退或越过证据截止时间会失败。 +- 资金、持仓及游标在一次advance中原子变更。资金不足、T+1、生命周期冲突或活动影子订单冲突不借股、不借款、不取消原订单,也不留下半笔状态。 +- 人工交易不是出入金,不更改现金流中性单位或初始资金;原始买卖账本入口继续使用原有计算,仅抽出可传固定金额的内部函数。 + +本机Core849项通过(9项原有ignore),其中15项新专项覆盖精度/摘要/关联/时间/顺序/无订单/部分撤单/原子失败/不重复和跨日出售。此结果不代表服务、完整影子请求或生产成交验收。 + +## 已核对的持久化入口 + +Paper `paper_manual_position_actions`保存确认、执行合同SHA、计划与order_ids;`paper_fills`及`paper_event_log.FillReceived`可以提供真实成交及观察事件序号。Live单证券动作在`live_manual_trade_intents`,逐笔事实在`live_broker_trade_facts`,对应`live_event_log.FillReceived`提供recorded_at和序号。事件序号表示持久化观察顺序,不冒充交易所执行顺序。 + +Live整仓的历史审计原来只有confirmation_hash,执行ID在另一个开始事件中;当前候选已将服务端生成的execution_id和所选account_id写入同一仓位审计详情,并校验非空ID和账户范围。旧历史仍只能依据原始审计/事件做唯一关联,不能猜测或重写。 + +费用仍需在读取层核对实际适配器合同:当前Paper账本收取commission+stamp_tax;Live事实的complete也按这两个已声明字段判定。不能仅凭complete名字断言其他费用不存在,不能以默认0补缺失。 + +## 必须继续,不能把本阶段当完成 + +1. 实现全部四类来源的权威PG读取、审计/动作/订单/成交/事件绑定与一致快照;未知/活动状态等待,不能变成空成功。 +2. 在API/Runner传递完整受控合同和源范围,补齐手工证券的历史资料/行情需求。当前没有任何运行入口调用此游标。 +3. 把观察事件与盘前、集合竞价、日度、分钟、收盘/结算阶段按完整时钟合并;跨交易日/会话外观察不可简单塞进on_minute或提前应用。 +4. 输出须区分外部人工成交与策略模拟成交,保留原始执行时间、观察时间、费用和实际投影时间线,不能宣称人工成交被独立验证。 +5. 完成两套隔离PG、真实引擎、完整HTTP和发布验证后,才可解除四类手工来源的纯比例影子拒绝门禁。 + +下一轮直接进行上述读取/引擎/结果链,不能重复15项基础用例或v2026.9.14.5固定三组回放替代集成。Source冻结、研究/信号暂停、现有3Paper/0Live与disabled不变;本轮无生产写入、真实订单或通知。