建立手工成交观察合同与原子回放游标

This commit is contained in:
boris
2026-09-14 07:30:19 +08:00
parent 8e7ae69b0b
commit 5e11f3da22
6 changed files with 1126 additions and 9 deletions
+11
View File
@@ -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<Self, String> {
let value = value.trim();
if value.is_empty() {
+1
View File
@@ -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;
+534
View File
@@ -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<Utc>,
pub actions: Vec<ManualExecutionAction>,
}
#[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<String>,
pub confirmed_at: DateTime<Utc>,
pub outcome: ManualActionOutcome,
pub orders: Vec<ManualExecutionOrder>,
}
#[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<String>,
pub source_adapter: String,
pub symbol: String,
pub side: OrderSide,
pub quantity: u32,
pub submitted_at: DateTime<Utc>,
pub terminal_at: DateTime<Utc>,
pub terminal_status: ManualOrderTerminalStatus,
pub fills: Vec<ManualExecutionFill>,
}
#[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<Utc>,
pub observed_at: DateTime<Utc>,
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<Decimal, String> {
self.price
.checked_mul(Decimal::from(self.quantity))
.ok_or_else(|| "manual fill gross amount overflow".into())
}
pub fn total_fees(&self) -> Result<Decimal, String> {
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<Vec<ManualFillObservation<'_>>, 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<String, String> {
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<DateTime<Utc>>,
}
#[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<Utc>,
pub executed_at: DateTime<Utc>,
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<Self, String> {
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<DateTime<Utc>> {
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<Utc>,
portfolio: &mut PortfolioState,
data: &DataSet,
has_pending_orders: bool,
) -> Result<Vec<ManualReplayApplication>, 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<AppliedManualFill, String> {
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;
@@ -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::<ManualExecutionReplay>(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::<ManualExecutionReplay>(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::<ManualExecutionReplay>(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::<ManualExecutionReplay>(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::<ManualExecutionReplay>(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::<ManualExecutionReplay>(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<_>>(),
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('-'));
}
+129 -9
View File
@@ -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<f64, String> {
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<f64, String> {
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<FixedMoney, String> {
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| {
+35
View File
@@ -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_taxLive事实的complete也按这两个已声明字段判定。不能仅凭complete名字断言其他费用不存在,不能以默认0补缺失。
## 必须继续,不能把本阶段当完成
1. 实现全部四类来源的权威PG读取、审计/动作/订单/成交/事件绑定与一致快照;未知/活动状态等待,不能变成空成功。
2. 在API/Runner传递完整受控合同和源范围,补齐手工证券的历史资料/行情需求。当前没有任何运行入口调用此游标。
3. 把观察事件与盘前、集合竞价、日度、分钟、收盘/结算阶段按完整时钟合并;跨交易日/会话外观察不可简单塞进on_minute或提前应用。
4. 输出须区分外部人工成交与策略模拟成交,保留原始执行时间、观察时间、费用和实际投影时间线,不能宣称人工成交被独立验证。
5. 完成两套隔离PG、真实引擎、完整HTTP和发布验证后,才可解除四类手工来源的纯比例影子拒绝门禁。
下一轮直接进行上述读取/引擎/结果链,不能重复15项基础用例或v2026.9.14.5固定三组回放替代集成。Source冻结、研究/信号暂停、现有3Paper/0Live与disabled不变;本轮无生产写入、真实订单或通知。