建立手工成交观察合同与原子回放游标
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('-'));
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user